@sigloch/graph-view-edit 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/README.md +100 -0
- package/bin/gve.mjs +41 -0
- package/dist/assets/index-Cxy3uYBx.css +1 -0
- package/dist/assets/index-UnMYySno.js +180 -0
- package/dist/index.html +13 -0
- package/package.json +50 -0
- package/src/config.mjs +49 -0
- package/vite.config.js +411 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>graph-view-edit</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-UnMYySno.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Cxy3uYBx.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sigloch/graph-view-edit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React/Vite viewer+editor over @sigloch/graphcode — 12 graph-views + 16 doc-views via a declarative View-Registry, plus an SE-Dashboard sibling route. Reads docs/graph/<member>.graph.json client-side (no 2nd Kuzu handle).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"gve": "bin/gve.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"bin",
|
|
12
|
+
"vite.config.js",
|
|
13
|
+
"src/config.mjs"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "vite",
|
|
17
|
+
"build": "vite build",
|
|
18
|
+
"preview": "vite preview",
|
|
19
|
+
"prepack": "npm run build",
|
|
20
|
+
"test": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"comment:deps": "Runtime = what vite.config.js + bin/gve.mjs load at Node level when serving the prebuilt dist/. The UI libs (react, react-dom, elkjs, @tanstack/react-table, zustand) are bundled INTO dist/ by vite and must stay devDependencies — shipping them would make consumers download the whole UI stack twice.",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@sigloch/contracts": "^0.8.0",
|
|
25
|
+
"@sigloch/graph-api-core": "^0.4.1",
|
|
26
|
+
"@sigloch/graphcode-client": "^0.2.0",
|
|
27
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
28
|
+
"vite": "^5.4.11",
|
|
29
|
+
"zod": "^4.3.6"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@playwright/test": "^1.58.2",
|
|
33
|
+
"@tanstack/react-table": "^8.20.5",
|
|
34
|
+
"@testing-library/react": "^16.0.1",
|
|
35
|
+
"elkjs": "^0.11.1",
|
|
36
|
+
"jsdom": "^25.0.1",
|
|
37
|
+
"react": "^18.3.1",
|
|
38
|
+
"react-dom": "^18.3.1",
|
|
39
|
+
"vitest": "^2.0.0",
|
|
40
|
+
"zustand": "^4.5.5"
|
|
41
|
+
},
|
|
42
|
+
"author": "andreas@siglochconsulting",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=22"
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static config loader (CR-GVE-101 / REQ-ops-config-static).
|
|
3
|
+
*
|
|
4
|
+
* Reads a versionable JSON config file (default `config.json`, see
|
|
5
|
+
* `config.example.json` for the template) and validates it against
|
|
6
|
+
* `ConfigSchema`. Missing file or missing fields fall back to schema
|
|
7
|
+
* defaults; a present-but-invalid file throws (fail fast, no silent
|
|
8
|
+
* fallback — schema-first per REQ-nfr-stack).
|
|
9
|
+
*
|
|
10
|
+
* Nested object defaults use `.prefault({})`, not `.default({})` (CR-GVE-221):
|
|
11
|
+
* since zod 4 `.default()` hands its value through RAW, so `.default({})` on
|
|
12
|
+
* `graph` would yield `{}` instead of `{path: …, host: null}` — the viewer
|
|
13
|
+
* would lose the graph path whenever config.json is absent. `.prefault()` runs
|
|
14
|
+
* the value through the inner schema, i.e. zod 3's `.default()` semantics.
|
|
15
|
+
* Defaults on primitives are unaffected and stay `.default(…)`.
|
|
16
|
+
*
|
|
17
|
+
* @author andreas@siglochconsulting
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
export const ConfigSchema = z.object({
|
|
24
|
+
graph: z
|
|
25
|
+
.object({
|
|
26
|
+
path: z.string().default('docs/graph/graph-view-edit.graph.json'),
|
|
27
|
+
host: z.string().nullable().default(null),
|
|
28
|
+
})
|
|
29
|
+
.prefault({}),
|
|
30
|
+
port: z.number().int().positive().default(4317),
|
|
31
|
+
ontologyDescriptor: z.string().default('se'),
|
|
32
|
+
tokenTheme: z.string().default('default'),
|
|
33
|
+
viewRegistryDefaults: z
|
|
34
|
+
.object({
|
|
35
|
+
view: z.string().nullable().default(null),
|
|
36
|
+
scope: z.string().nullable().default(null),
|
|
37
|
+
})
|
|
38
|
+
.prefault({}),
|
|
39
|
+
edit: z
|
|
40
|
+
.object({
|
|
41
|
+
enabled: z.boolean().default(false),
|
|
42
|
+
})
|
|
43
|
+
.prefault({}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export function loadConfig(path = process.env.GVE_CONFIG_PATH || resolve(process.cwd(), 'config.json')) {
|
|
47
|
+
const raw = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
48
|
+
return ConfigSchema.parse(raw);
|
|
49
|
+
}
|
package/vite.config.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync, statSync, watch } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { defineConfig } from 'vite';
|
|
4
|
+
import react from '@vitejs/plugin-react';
|
|
5
|
+
import { DefaultRuleEngine, SE_DESCRIPTOR } from '@sigloch/graph-api-core';
|
|
6
|
+
import { ONTOLOGY_VERSION, RULES_VERSION } from '@sigloch/contracts/se';
|
|
7
|
+
// CR-GC-265: these eight come from the read-side client, not the substrate.
|
|
8
|
+
// They are pure projection plus a node:net socket call — depending on
|
|
9
|
+
// @sigloch/graphcode for them pulled kuzu-wasm, the MCP SDK and the TypeScript
|
|
10
|
+
// compiler (~99 MB) into a viewer that must never open a store.
|
|
11
|
+
import {
|
|
12
|
+
computeReadiness,
|
|
13
|
+
readinessPanel,
|
|
14
|
+
recommendationsPanel,
|
|
15
|
+
artifactsPanel,
|
|
16
|
+
healthPanel,
|
|
17
|
+
VIEW_FILENAMES,
|
|
18
|
+
callHost,
|
|
19
|
+
HOST_SOCK_BASENAME,
|
|
20
|
+
} from '@sigloch/graphcode-client';
|
|
21
|
+
import { loadConfig } from './src/config.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Static config (CR-GVE-138 / REQ-ops-config-static) — loadConfig validates
|
|
25
|
+
* config.json (or GVE_CONFIG_PATH) against ConfigSchema once at server start;
|
|
26
|
+
* missing file = schema defaults, invalid file = fail fast. The result drives
|
|
27
|
+
* the dev-server port below and is served to the browser at runtime via
|
|
28
|
+
* GET /api/config (configApiPlugin → src/app-config.mjs initAppConfig), so
|
|
29
|
+
* the client reads the SAME validated config — nothing is baked into the
|
|
30
|
+
* bundle, one prebuilt dist/ serves any repo (CR-GVE-181).
|
|
31
|
+
*
|
|
32
|
+
* Unter vitest wird die COMMITTETE config.example.json gepinnt (analog
|
|
33
|
+
* tests/helpers/dev-server.mjs): eine lokale config.json — z.B. ein User-
|
|
34
|
+
* Experiment mit anderem graph.path — darf die Suite nicht auf einen fremden
|
|
35
|
+
* Graphen umleiten. Explizites GVE_CONFIG_PATH gewinnt weiterhin.
|
|
36
|
+
*/
|
|
37
|
+
const APP_CONFIG = loadConfig(
|
|
38
|
+
process.env.GVE_CONFIG_PATH ?? (process.env.VITEST ? 'config.example.json' : undefined),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Repo root this app instance reads/writes (CR-GVE-127). Defaults to
|
|
43
|
+
* process.cwd() — zero behavior change for the normal case (running inside
|
|
44
|
+
* the repo it's viewing). Overridable via GVE_REPO_ROOT so the SAME app
|
|
45
|
+
* instance can point at a different repo's docs/graph/*.graph.json +
|
|
46
|
+
* .graphcode/host.sock — e.g. an isolated sandbox copy, safe for
|
|
47
|
+
* destructive testing without touching the live tracked project graph.
|
|
48
|
+
*/
|
|
49
|
+
function resolveRepoRoot() {
|
|
50
|
+
return process.env.GVE_REPO_ROOT || process.cwd();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Serves docs/graph/*.json from the resolved repo root (CR-GVE-127) instead
|
|
55
|
+
* of relying on Vite's default static passthrough from its OWN project
|
|
56
|
+
* root — without this, GVE_REPO_ROOT would redirect the API plugins but
|
|
57
|
+
* Renderer.jsx's graph fetch would still silently read the wrong repo's
|
|
58
|
+
* data. No-op (falls through to Vite's own static handling) when
|
|
59
|
+
* GVE_REPO_ROOT is unset.
|
|
60
|
+
*/
|
|
61
|
+
function graphStaticPlugin() {
|
|
62
|
+
const middleware = (req, res, next) => {
|
|
63
|
+
const repoRoot = process.env.GVE_REPO_ROOT;
|
|
64
|
+
if (!repoRoot || !req.url?.startsWith('/docs/graph/')) return next();
|
|
65
|
+
const filePath = join(repoRoot, req.url);
|
|
66
|
+
if (!existsSync(filePath)) return next();
|
|
67
|
+
res.setHeader('Content-Type', 'application/json');
|
|
68
|
+
res.end(readFileSync(filePath));
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
name: 'gve-graph-static-override',
|
|
72
|
+
configureServer(server) {
|
|
73
|
+
server.middlewares.use(middleware);
|
|
74
|
+
},
|
|
75
|
+
configurePreviewServer(server) {
|
|
76
|
+
server.middlewares.use(middleware);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* GET /api/config (CR-GVE-181) — serves the validated server-side config to
|
|
83
|
+
* the browser at runtime instead of baking it into the bundle via `define`,
|
|
84
|
+
* so ONE prebuilt dist/ serves any repo. graph.path is resolved against the
|
|
85
|
+
* active repo root per request: when the configured file doesn't exist there
|
|
86
|
+
* (a foreign repo without its own gve config.json), the first
|
|
87
|
+
* docs/graph/*.graph.json wins — the same discovery the dashboard uses, so
|
|
88
|
+
* `gve --repo <path>` needs no per-repo config to find the graph.
|
|
89
|
+
*/
|
|
90
|
+
function configApiPlugin() {
|
|
91
|
+
const middleware = (req, res, next) => {
|
|
92
|
+
if (req.url !== '/api/config') return next();
|
|
93
|
+
const repoRoot = resolveRepoRoot();
|
|
94
|
+
const config = structuredClone(APP_CONFIG);
|
|
95
|
+
if (!existsSync(join(repoRoot, config.graph.path))) {
|
|
96
|
+
const graphDir = join(repoRoot, 'docs', 'graph');
|
|
97
|
+
const hit = existsSync(graphDir)
|
|
98
|
+
? readdirSync(graphDir).find((f) => f.endsWith('.graph.json'))
|
|
99
|
+
: null;
|
|
100
|
+
if (hit) config.graph.path = `docs/graph/${hit}`;
|
|
101
|
+
}
|
|
102
|
+
res.setHeader('Content-Type', 'application/json');
|
|
103
|
+
res.end(JSON.stringify(config));
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
name: 'gve-config-api',
|
|
107
|
+
configureServer(server) {
|
|
108
|
+
server.middlewares.use(middleware);
|
|
109
|
+
},
|
|
110
|
+
configurePreviewServer(server) {
|
|
111
|
+
server.middlewares.use(middleware);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* SE-Dashboard API (CR-GVE-117) — GET /api/dashboard, computed server-side.
|
|
118
|
+
* @sigloch/graphcode's panel functions can't be imported into browser code
|
|
119
|
+
* (Node-only internals break the Vite build — see CR-GVE-120's VIEW_FILENAMES
|
|
120
|
+
* finding). vite.config.js itself is never bundled for the browser, so this
|
|
121
|
+
* middleware is the safe place for that computation; the React dashboard
|
|
122
|
+
* just fetches the JSON like any other static resource.
|
|
123
|
+
*/
|
|
124
|
+
function dashboardApiPlugin() {
|
|
125
|
+
const cwd = resolveRepoRoot();
|
|
126
|
+
const GRAPH_DIR = join(cwd, 'docs', 'graph');
|
|
127
|
+
const VIEWS_DIR = join(cwd, 'docs', 'views');
|
|
128
|
+
const engine = new DefaultRuleEngine(SE_DESCRIPTOR.version);
|
|
129
|
+
engine.register(SE_DESCRIPTOR.rules ?? []);
|
|
130
|
+
|
|
131
|
+
function findGraphFile() {
|
|
132
|
+
if (!existsSync(GRAPH_DIR)) return null;
|
|
133
|
+
const hit = readdirSync(GRAPH_DIR).find((f) => f.endsWith('.graph.json'));
|
|
134
|
+
return hit ? join(GRAPH_DIR, hit) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function loadGraph(file) {
|
|
138
|
+
const json = JSON.parse(readFileSync(file, 'utf8'));
|
|
139
|
+
const nodes = (json.elements ?? []).map((e) => {
|
|
140
|
+
const { id, type, name, description, ...rest } = e;
|
|
141
|
+
return { uid: id, type, name, description: description ?? '', attributes: rest };
|
|
142
|
+
});
|
|
143
|
+
const edges = (json.traces ?? []).map((t) => {
|
|
144
|
+
const { source, target, type, ...rest } = t;
|
|
145
|
+
return { sourceId: source, targetId: target, edgeType: type, attributes: rest };
|
|
146
|
+
});
|
|
147
|
+
return { nodes, edges };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function scanArtifacts(graphFile) {
|
|
151
|
+
const graphMtime = existsSync(graphFile) ? statSync(graphFile).mtimeMs : 0;
|
|
152
|
+
const items = Object.entries(VIEW_FILENAMES).map(([id, fname]) => {
|
|
153
|
+
const p = join(VIEWS_DIR, fname);
|
|
154
|
+
const exists = existsSync(p);
|
|
155
|
+
const staleVsGraph = exists ? statSync(p).mtimeMs < graphMtime : false;
|
|
156
|
+
return { id, label: id, exists, staleVsGraph };
|
|
157
|
+
});
|
|
158
|
+
return artifactsPanel(items);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function synthHealth(graph) {
|
|
162
|
+
return healthPanel({
|
|
163
|
+
status: 'ok',
|
|
164
|
+
store: 'committed graph.json (read-only)',
|
|
165
|
+
gate: 'n/a — dashboard is read-only (writes go through MCP mutate())',
|
|
166
|
+
versions: {
|
|
167
|
+
ontology: ONTOLOGY_VERSION,
|
|
168
|
+
rules: RULES_VERSION,
|
|
169
|
+
engine: SE_DESCRIPTOR.version,
|
|
170
|
+
elements: graph.nodes.length,
|
|
171
|
+
traces: graph.edges.length,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildDashboard() {
|
|
177
|
+
const file = findGraphFile();
|
|
178
|
+
if (!file) return { member: null, empty: true, error: 'no docs/graph/*.graph.json found', computedAt: new Date().toISOString() };
|
|
179
|
+
const graph = loadGraph(file);
|
|
180
|
+
const violations = engine.evaluate(graph);
|
|
181
|
+
const report = computeReadiness(violations, graph);
|
|
182
|
+
return {
|
|
183
|
+
member: basename(file).replace(/\.graph\.json$/, ''),
|
|
184
|
+
empty: graph.nodes.length === 0,
|
|
185
|
+
readiness: readinessPanel(report),
|
|
186
|
+
recommendations: recommendationsPanel(violations, 50),
|
|
187
|
+
artifacts: scanArtifacts(file),
|
|
188
|
+
health: synthHealth(graph),
|
|
189
|
+
computedAt: new Date().toISOString(),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const middleware = (req, res, next) => {
|
|
194
|
+
if (req.url !== '/api/dashboard') return next();
|
|
195
|
+
res.setHeader('Content-Type', 'application/json');
|
|
196
|
+
res.end(JSON.stringify(buildDashboard()));
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
name: 'gve-dashboard-api',
|
|
201
|
+
configureServer(server) {
|
|
202
|
+
server.middlewares.use(middleware);
|
|
203
|
+
},
|
|
204
|
+
configurePreviewServer(server) {
|
|
205
|
+
server.middlewares.use(middleware);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The actual request handling for POST /api/mutate (CR-GVE-008), factored
|
|
212
|
+
* out of the plugin's HTTP glue so it's directly unit-testable (no server,
|
|
213
|
+
* no sockets) with an injected `callHostImpl` — same DI pattern as
|
|
214
|
+
* command-bridge.mjs's fetchImpl. Forwards {commands, baseVersion,
|
|
215
|
+
* consumerId} to graph_mutate over the elected graphcode host's
|
|
216
|
+
* `.graphcode/host.sock` (CR-GC-241) — never opens a second Kuzu handle.
|
|
217
|
+
* Fails loud (throws) when the socket is absent/dead instead of a silent
|
|
218
|
+
* no-op; the plugin's HTTP wrapper turns that into a clear 503.
|
|
219
|
+
*
|
|
220
|
+
* graph_mutate does NOT auto-export docs/graph/*.graph.json (graph_export is
|
|
221
|
+
* a separate tool, "the single sync path" per its own description) — this
|
|
222
|
+
* repo's read path (Option A) reads that committed file, so without an
|
|
223
|
+
* export a refetch after editing would still show stale data. On a
|
|
224
|
+
* successful mutate with real changes, this also calls graph_export with
|
|
225
|
+
* force:true — graph_export's refuse-to-clobber guard aborts on ANY
|
|
226
|
+
* deletion by design (not just stale/drifted state — it protects against a
|
|
227
|
+
* stale/parallel process silently dropping committed data, a real past
|
|
228
|
+
* incident per its own source comment). That staleness risk doesn't apply
|
|
229
|
+
* here: this export runs immediately after a mutation that just passed
|
|
230
|
+
* THIS SAME live gate in THIS SAME request, so the deletion is known-
|
|
231
|
+
* intentional, not a stale process's guess (best-effort either way: the
|
|
232
|
+
* data is already safely persisted in Kuzu, so an export failure is
|
|
233
|
+
* attached as `exportWarning` rather than turning a successful edit into a
|
|
234
|
+
* failure response).
|
|
235
|
+
*/
|
|
236
|
+
export async function handleMutateRequest(body, { repoRoot = process.cwd(), callHostImpl = callHost } = {}) {
|
|
237
|
+
const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
|
|
238
|
+
const result = await callHostImpl(socketPath, 'graph_mutate', body);
|
|
239
|
+
if (result.success && result.mutations > 0) {
|
|
240
|
+
try {
|
|
241
|
+
await callHostImpl(socketPath, 'graph_export', { force: true });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
return { ...result, exportWarning: `graph_export failed after a successful mutate: ${err.message}` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* POST /api/realize (CR-GVE-135 / REQ-real-bind) — same host.sock transport
|
|
251
|
+
* and post-write export rationale as handleMutateRequest, but for the
|
|
252
|
+
* graph_realize tool: binds FUNC↔codeRef (R-20) and optionally TEST↔testRef
|
|
253
|
+
* (R-19) through the same Apply-Gate (graph_realize composes
|
|
254
|
+
* harness.mutate, no parallel write path).
|
|
255
|
+
*/
|
|
256
|
+
export async function handleRealizeRequest(body, { repoRoot = process.cwd(), callHostImpl = callHost } = {}) {
|
|
257
|
+
const socketPath = join(repoRoot, '.graphcode', HOST_SOCK_BASENAME);
|
|
258
|
+
const result = await callHostImpl(socketPath, 'graph_realize', body);
|
|
259
|
+
if (result.success !== false) {
|
|
260
|
+
try {
|
|
261
|
+
await callHostImpl(socketPath, 'graph_export', { force: true });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
return { ...result, exportWarning: `graph_export failed after a successful realize: ${err.message}` };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* POST /api/mutate (CR-GVE-008) — bridges the edit-surface's write path to
|
|
271
|
+
* the SAME Apply-Gate this repo's own graphcode MCP session uses, via the
|
|
272
|
+
* local host.sock shim (CR-GC-241) rather than a second Kuzu owner.
|
|
273
|
+
* POST /api/realize (CR-GVE-135) rides the same plugin.
|
|
274
|
+
*/
|
|
275
|
+
function mutateApiPlugin() {
|
|
276
|
+
const cwd = resolveRepoRoot();
|
|
277
|
+
|
|
278
|
+
const middleware = (req, res, next) => {
|
|
279
|
+
const isMutate = req.url === '/api/mutate';
|
|
280
|
+
const isRealize = req.url === '/api/realize';
|
|
281
|
+
if ((!isMutate && !isRealize) || req.method !== 'POST') return next();
|
|
282
|
+
let raw = '';
|
|
283
|
+
req.on('data', (chunk) => { raw += chunk; });
|
|
284
|
+
req.on('end', async () => {
|
|
285
|
+
res.setHeader('Content-Type', 'application/json');
|
|
286
|
+
let body;
|
|
287
|
+
try {
|
|
288
|
+
body = JSON.parse(raw);
|
|
289
|
+
} catch {
|
|
290
|
+
res.statusCode = 400;
|
|
291
|
+
res.end(JSON.stringify({ success: false, error: 'invalid JSON body' }));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const result = isRealize
|
|
296
|
+
? await handleRealizeRequest(body, { repoRoot: cwd })
|
|
297
|
+
: await handleMutateRequest(body, { repoRoot: cwd });
|
|
298
|
+
res.end(JSON.stringify(result));
|
|
299
|
+
} catch (err) {
|
|
300
|
+
// A dead/absent socket is a 503 "start a session"; a tool-level
|
|
301
|
+
// rejection (e.g. graph_realize: unknown funcUid) is the tool's own
|
|
302
|
+
// message, not a host problem (CR-GVE-135).
|
|
303
|
+
const hostDown = /ENOENT|ECONNREFUSED|ECONNRESET|EPIPE|socket/i.test(err.message);
|
|
304
|
+
res.statusCode = hostDown ? 503 : 422;
|
|
305
|
+
res.end(
|
|
306
|
+
JSON.stringify({
|
|
307
|
+
success: false,
|
|
308
|
+
error: hostDown
|
|
309
|
+
? `no graphcode host reachable for this repo (${err.message}) — start a graphcode session first`
|
|
310
|
+
: err.message,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
name: 'gve-mutate-api',
|
|
319
|
+
configureServer(server) {
|
|
320
|
+
server.middlewares.use(middleware);
|
|
321
|
+
},
|
|
322
|
+
configurePreviewServer(server) {
|
|
323
|
+
server.middlewares.use(middleware);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* SSE Invalidate hub (CR-GVE-139 / REQ-live-sse), factored out of the plugin
|
|
330
|
+
* HTTP glue for direct unit-testability (same DI pattern as
|
|
331
|
+
* handleMutateRequest). GET /api/events holds the response open as a
|
|
332
|
+
* text/event-stream client; broadcast() pushes an `invalidate`
|
|
333
|
+
* LiveUpdateEvent naming the affected domains, which sse-client.mjs
|
|
334
|
+
* (createSSEClient) hands to each panel for a SELECTIVE refresh.
|
|
335
|
+
*/
|
|
336
|
+
export function createEventsHub() {
|
|
337
|
+
const clients = new Set();
|
|
338
|
+
return {
|
|
339
|
+
middleware(req, res, next) {
|
|
340
|
+
if (req.url !== '/api/events') return next();
|
|
341
|
+
res.writeHead(200, {
|
|
342
|
+
'Content-Type': 'text/event-stream',
|
|
343
|
+
'Cache-Control': 'no-cache',
|
|
344
|
+
Connection: 'keep-alive',
|
|
345
|
+
});
|
|
346
|
+
res.write('retry: 3000\n\n');
|
|
347
|
+
clients.add(res);
|
|
348
|
+
req.on('close', () => clients.delete(res));
|
|
349
|
+
},
|
|
350
|
+
broadcast(domains) {
|
|
351
|
+
const frame = `event: invalidate\ndata: ${JSON.stringify({ domains })}\n\n`;
|
|
352
|
+
for (const res of clients) res.write(frame);
|
|
353
|
+
},
|
|
354
|
+
clientCount: () => clients.size,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* GET /api/events (CR-GVE-139). The invalidate TRIGGER is a watcher on
|
|
360
|
+
* docs/graph/*.graph.json — the single point every write converges on
|
|
361
|
+
* (own edits via /api/mutate's post-mutate export AND external writers like
|
|
362
|
+
* a graphcode MCP session exporting), so there's exactly one refresh path
|
|
363
|
+
* instead of a per-writer special case. A graph change also invalidates
|
|
364
|
+
* rules/readiness — both are derived from the same committed file.
|
|
365
|
+
*/
|
|
366
|
+
function eventsApiPlugin() {
|
|
367
|
+
const hub = createEventsHub();
|
|
368
|
+
const graphDir = join(resolveRepoRoot(), 'docs', 'graph');
|
|
369
|
+
let debounce = null;
|
|
370
|
+
function armWatcher() {
|
|
371
|
+
if (!existsSync(graphDir)) return;
|
|
372
|
+
const watcher = watch(graphDir, (eventType, filename) => {
|
|
373
|
+
if (!filename?.endsWith('.graph.json')) return;
|
|
374
|
+
clearTimeout(debounce);
|
|
375
|
+
debounce = setTimeout(() => hub.broadcast(['graph', 'rules', 'readiness']), 150);
|
|
376
|
+
});
|
|
377
|
+
// Never hold the process open (vitest runs this config's plugins too —
|
|
378
|
+
// an un-unref'd FSWatcher kept its worker from exiting).
|
|
379
|
+
watcher.unref();
|
|
380
|
+
}
|
|
381
|
+
return {
|
|
382
|
+
name: 'gve-events-api',
|
|
383
|
+
configureServer(server) {
|
|
384
|
+
armWatcher();
|
|
385
|
+
server.middlewares.use(hub.middleware);
|
|
386
|
+
},
|
|
387
|
+
configurePreviewServer(server) {
|
|
388
|
+
armWatcher();
|
|
389
|
+
server.middlewares.use(hub.middleware);
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export default defineConfig({
|
|
395
|
+
plugins: [react(), graphStaticPlugin(), configApiPlugin(), dashboardApiPlugin(), mutateApiPlugin(), eventsApiPlugin()],
|
|
396
|
+
// config.port is the default; an explicit CLI --port (e.g. the test suites'
|
|
397
|
+
// tests/helpers/dev-server.mjs, which picks a free OS-assigned port with
|
|
398
|
+
// --strictPort) still overrides it, per Vite's precedence.
|
|
399
|
+
server: { port: APP_CONFIG.port },
|
|
400
|
+
preview: { port: APP_CONFIG.port },
|
|
401
|
+
test: {
|
|
402
|
+
// Vitest's own default (test/spec files) plus the NFR regression guards
|
|
403
|
+
// (CR-GVE-115), which intentionally use .bench.mjs/.smoke.mjs names to
|
|
404
|
+
// read as benchmarks/smoke tests rather than unit tests — without this,
|
|
405
|
+
// `npm test` would silently never run them.
|
|
406
|
+
include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', 'tests/nfr/**/*.{bench,smoke}.mjs'],
|
|
407
|
+
// Setzt den Modul-Singleton-Graph-Store (CR-GVE-192) vor jedem Test zurück,
|
|
408
|
+
// sonst leckt ein in Test A geladener Graph in Test B.
|
|
409
|
+
setupFiles: ['tests/setup.mjs'],
|
|
410
|
+
},
|
|
411
|
+
});
|