@nwire/studio 0.12.1 → 0.13.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/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
package/vite.config.ts
CHANGED
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
readSync,
|
|
13
13
|
statSync,
|
|
14
14
|
} from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
readManifest,
|
|
17
|
+
listTelemetryRuns,
|
|
18
|
+
readTelemetryRun,
|
|
19
|
+
type TelemetryRunMeta,
|
|
20
|
+
} from "./src/server/nwire-read";
|
|
15
21
|
import { spawnSync } from "node:child_process";
|
|
16
22
|
import { RunnerSupervisor, inspectHealthCheck } from "@nwire/supervisor";
|
|
17
23
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
@@ -220,9 +226,28 @@ function targetCwd(req: IncomingMessage): string | null {
|
|
|
220
226
|
* Studio calls this on every `GET /__nwire/manifest.json` so a user
|
|
221
227
|
* editing source never sees stale data — the cache rebuilds itself on
|
|
222
228
|
* the next page load.
|
|
229
|
+
*
|
|
230
|
+
* The auto-rebuild is skipped when:
|
|
231
|
+
* - `NWIRE_NO_AUTOCACHE=1` is set in the environment (e.g. the e2e harness
|
|
232
|
+
* points Studio at a fixture directory that has a committed manifest but no
|
|
233
|
+
* real source — running `nwire cache` there would overwrite the fixture).
|
|
234
|
+
* - The cwd has no `package.json` (nothing to scan; serve whatever manifest
|
|
235
|
+
* is already on disk rather than producing an empty one).
|
|
236
|
+
* In both cases we fall through to serving the existing manifest, or 404 if
|
|
237
|
+
* it isn't there yet.
|
|
223
238
|
*/
|
|
224
239
|
function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } = {}): boolean {
|
|
225
240
|
const manifestPath = resolve(cwd, ".nwire", "manifest.json");
|
|
241
|
+
|
|
242
|
+
// Guard: a fixture-only directory (no package.json) or an explicit opt-out
|
|
243
|
+
// from the caller env. Serve whatever is on disk; don't overwrite it.
|
|
244
|
+
if (process.env.NWIRE_NO_AUTOCACHE === "1" && !opts.force) {
|
|
245
|
+
return existsSync(manifestPath);
|
|
246
|
+
}
|
|
247
|
+
if (!existsSync(resolve(cwd, "package.json")) && !opts.force) {
|
|
248
|
+
return existsSync(manifestPath);
|
|
249
|
+
}
|
|
250
|
+
|
|
226
251
|
const cliPath = resolve(here, "..", "nwire-cli", "dist", "cli.js");
|
|
227
252
|
if (!existsSync(cliPath)) {
|
|
228
253
|
return existsSync(manifestPath);
|
|
@@ -243,6 +268,63 @@ function ensureCacheBuilt(cwd: string = consumerCwd, opts: { force?: boolean } =
|
|
|
243
268
|
return existsSync(manifestPath);
|
|
244
269
|
}
|
|
245
270
|
|
|
271
|
+
// ── Telemetry run helpers ─────────────────────────────────────────────────
|
|
272
|
+
// listTelemetryRuns, readTelemetryRun, TelemetryRunMeta imported from
|
|
273
|
+
// ./src/server/nwire-read — shared with studio-host-api.ts.
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Open a JSONL file tail: poll for new content and call `onLine` with each
|
|
277
|
+
* newly appended, parsed JSON record. Returns a stop handle. Same pattern as
|
|
278
|
+
* `openLogTail` but parses JSON (telemetry records) instead of raw text.
|
|
279
|
+
*/
|
|
280
|
+
function openJsonlTail(path: string, onRecord: (rec: unknown) => void): { stop: () => void } {
|
|
281
|
+
if (!existsSync(path)) return { stop: () => {} };
|
|
282
|
+
let offset = 0;
|
|
283
|
+
let leftover = "";
|
|
284
|
+
try {
|
|
285
|
+
offset = statSync(path).size;
|
|
286
|
+
} catch {
|
|
287
|
+
offset = 0;
|
|
288
|
+
}
|
|
289
|
+
let stopped = false;
|
|
290
|
+
const poll = setInterval(() => {
|
|
291
|
+
if (stopped) return;
|
|
292
|
+
try {
|
|
293
|
+
const size = statSync(path).size;
|
|
294
|
+
if (size <= offset) return;
|
|
295
|
+
const fd = openSync(path, "r");
|
|
296
|
+
try {
|
|
297
|
+
const buf = Buffer.alloc(size - offset);
|
|
298
|
+
readSync(fd, buf, 0, buf.length, offset);
|
|
299
|
+
offset = size;
|
|
300
|
+
const text = leftover + buf.toString("utf8");
|
|
301
|
+
const lines = text.split(/\r?\n/);
|
|
302
|
+
// Last element may be an incomplete line — hold it over.
|
|
303
|
+
leftover = lines.pop() ?? "";
|
|
304
|
+
for (const line of lines) {
|
|
305
|
+
const trimmed = line.trim();
|
|
306
|
+
if (!trimmed) continue;
|
|
307
|
+
try {
|
|
308
|
+
onRecord(JSON.parse(trimmed));
|
|
309
|
+
} catch {
|
|
310
|
+
// malformed JSON line — skip
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} finally {
|
|
314
|
+
closeSync(fd);
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// file removed or rotated — stop quietly
|
|
318
|
+
}
|
|
319
|
+
}, 250);
|
|
320
|
+
return {
|
|
321
|
+
stop: () => {
|
|
322
|
+
stopped = true;
|
|
323
|
+
clearInterval(poll);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
246
328
|
/**
|
|
247
329
|
* Vite middleware that exposes the consumer's `.nwire/manifest.json` to the
|
|
248
330
|
* Studio dev server. If the cache is missing, build it on the fly so the
|
|
@@ -390,7 +472,6 @@ function nwireDataPlugin() {
|
|
|
390
472
|
res.end(JSON.stringify({ error: "unknown project" }));
|
|
391
473
|
return;
|
|
392
474
|
}
|
|
393
|
-
const manifestPath = resolve(cwd, ".nwire", "manifest.json");
|
|
394
475
|
// Always run the if-stale path. When the fingerprint matches,
|
|
395
476
|
// the CLI exits in a few ms with no spawn cost beyond its own
|
|
396
477
|
// bootstrap; when source moved, the manifest is rebuilt before
|
|
@@ -411,8 +492,14 @@ function nwireDataPlugin() {
|
|
|
411
492
|
);
|
|
412
493
|
return;
|
|
413
494
|
}
|
|
495
|
+
const raw = readManifest(cwd);
|
|
496
|
+
if (!raw) {
|
|
497
|
+
res.statusCode = 404;
|
|
498
|
+
res.end(JSON.stringify({ error: "manifest.json missing after build" }));
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
414
501
|
res.setHeader("Content-Type", "application/json");
|
|
415
|
-
res.end(
|
|
502
|
+
res.end(raw);
|
|
416
503
|
});
|
|
417
504
|
|
|
418
505
|
// Source viewer — reads a file from disk, returns { content, language }.
|
|
@@ -461,6 +548,137 @@ function nwireDataPlugin() {
|
|
|
461
548
|
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
462
549
|
}
|
|
463
550
|
});
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Telemetry run list + single-run records.
|
|
554
|
+
*
|
|
555
|
+
* GET /__nwire/telemetry/runs → { runs: TelemetryRunMeta[] }
|
|
556
|
+
* GET /__nwire/telemetry/runs/:id → { id, records: unknown[] }
|
|
557
|
+
*
|
|
558
|
+
* Both honor `?project=<cwd>`.
|
|
559
|
+
*/
|
|
560
|
+
server.middlewares.use("/__nwire/telemetry/runs", (req, res) => {
|
|
561
|
+
const cwd = targetCwd(req);
|
|
562
|
+
if (!cwd) {
|
|
563
|
+
res.statusCode = 400;
|
|
564
|
+
res.setHeader("Content-Type", "application/json");
|
|
565
|
+
res.end(JSON.stringify({ error: "unknown project" }));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// connect strips the mount prefix, so req.url is "/" (or "") for the
|
|
569
|
+
// list and "/<id>" for a single run.
|
|
570
|
+
const sub = new URL(req.url ?? "/", "http://x").pathname;
|
|
571
|
+
|
|
572
|
+
// Exact: list all runs.
|
|
573
|
+
if (sub === "/" || sub === "") {
|
|
574
|
+
res.setHeader("Content-Type", "application/json");
|
|
575
|
+
res.end(JSON.stringify({ runs: listTelemetryRuns(cwd) }));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Sub-path: single run records.
|
|
580
|
+
const runIdMatch = /^\/([^/]+)$/.exec(sub);
|
|
581
|
+
if (runIdMatch) {
|
|
582
|
+
const runId = decodeURIComponent(runIdMatch[1]!);
|
|
583
|
+
const file = resolve(cwd, ".nwire", "telemetry", `${runId}.jsonl`);
|
|
584
|
+
if (!existsSync(file)) {
|
|
585
|
+
res.statusCode = 404;
|
|
586
|
+
res.setHeader("Content-Type", "application/json");
|
|
587
|
+
res.end(JSON.stringify({ error: "run not found", id: runId }));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const records = readTelemetryRun(cwd, runId);
|
|
591
|
+
res.setHeader("Content-Type", "application/json");
|
|
592
|
+
res.end(JSON.stringify({ id: runId, records }));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
res.statusCode = 404;
|
|
597
|
+
res.setHeader("Content-Type", "application/json");
|
|
598
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Live telemetry tail (SSE).
|
|
603
|
+
* GET /__nwire/telemetry/live[?project=<cwd>]
|
|
604
|
+
*
|
|
605
|
+
* Finds the current (newest) run file and tails it, streaming each new
|
|
606
|
+
* JSON record as an SSE `data:` event. Sends backfill (the entire current
|
|
607
|
+
* file at connection time) first, then polls for appended lines.
|
|
608
|
+
* Keepalive comments every 25 s to prevent proxy timeouts.
|
|
609
|
+
*/
|
|
610
|
+
server.middlewares.use("/__nwire/telemetry/live", (req, res) => {
|
|
611
|
+
const cwd = targetCwd(req);
|
|
612
|
+
if (!cwd) {
|
|
613
|
+
res.statusCode = 400;
|
|
614
|
+
res.setHeader("Content-Type", "application/json");
|
|
615
|
+
res.end(JSON.stringify({ error: "unknown project" }));
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const runs = listTelemetryRuns(cwd);
|
|
619
|
+
const current = runs[0]; // newest, or undefined when no runs yet
|
|
620
|
+
const filePath = current
|
|
621
|
+
? resolve(cwd, ".nwire", "telemetry", `${current.id}.jsonl`)
|
|
622
|
+
: null;
|
|
623
|
+
|
|
624
|
+
res.statusCode = 200;
|
|
625
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
626
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
627
|
+
res.setHeader("Connection", "keep-alive");
|
|
628
|
+
|
|
629
|
+
// Emit a meta event so the client knows which run it's tailing.
|
|
630
|
+
const runMeta = current ?? null;
|
|
631
|
+
try {
|
|
632
|
+
res.write(`event: run\ndata: ${JSON.stringify(runMeta)}\n\n`);
|
|
633
|
+
} catch {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Backfill: send all records already in the file.
|
|
638
|
+
if (filePath && existsSync(filePath)) {
|
|
639
|
+
const existing = readTelemetryRun(cwd, current!.id);
|
|
640
|
+
for (const rec of existing) {
|
|
641
|
+
try {
|
|
642
|
+
res.write(`data: ${JSON.stringify(rec)}\n\n`);
|
|
643
|
+
} catch {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (!filePath) {
|
|
650
|
+
// No run file yet — just keepalive and wait.
|
|
651
|
+
const keepalive = setInterval(() => {
|
|
652
|
+
try {
|
|
653
|
+
res.write(": keepalive\n\n");
|
|
654
|
+
} catch {
|
|
655
|
+
clearInterval(keepalive);
|
|
656
|
+
}
|
|
657
|
+
}, 25_000);
|
|
658
|
+
req.on("close", () => clearInterval(keepalive));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Tail for new lines appended after the backfill.
|
|
663
|
+
const tail = openJsonlTail(filePath, (rec) => {
|
|
664
|
+
try {
|
|
665
|
+
res.write(`data: ${JSON.stringify(rec)}\n\n`);
|
|
666
|
+
} catch {
|
|
667
|
+
// socket gone — tail.stop() will fire via req.close
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
const keepalive = setInterval(() => {
|
|
671
|
+
try {
|
|
672
|
+
res.write(": keepalive\n\n");
|
|
673
|
+
} catch {
|
|
674
|
+
clearInterval(keepalive);
|
|
675
|
+
}
|
|
676
|
+
}, 25_000);
|
|
677
|
+
req.on("close", () => {
|
|
678
|
+
tail.stop();
|
|
679
|
+
clearInterval(keepalive);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
464
682
|
},
|
|
465
683
|
};
|
|
466
684
|
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { normalizeCache } from "../normalize-cache";
|
|
3
|
-
|
|
4
|
-
describe("normalizeCache", () => {
|
|
5
|
-
it("fills every expected array when input is empty", () => {
|
|
6
|
-
const { cache, missingFields } = normalizeCache({});
|
|
7
|
-
expect(cache).not.toBeNull();
|
|
8
|
-
expect(cache?.apps).toEqual([]);
|
|
9
|
-
expect(cache?.actions).toEqual([]);
|
|
10
|
-
expect(cache?.events).toEqual([]);
|
|
11
|
-
expect(cache?.actors).toEqual([]);
|
|
12
|
-
expect(cache?.projections).toEqual([]);
|
|
13
|
-
expect(cache?.queries).toEqual([]);
|
|
14
|
-
expect(cache?.resolvers).toEqual([]);
|
|
15
|
-
expect(cache?.routes).toEqual([]);
|
|
16
|
-
expect(cache?.workflows).toEqual([]);
|
|
17
|
-
expect(cache?.graph).toEqual({ events: [] });
|
|
18
|
-
expect(missingFields).toContain("resolvers");
|
|
19
|
-
expect(missingFields).toContain("workflows");
|
|
20
|
-
expect(missingFields).toContain("graph");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("preserves arrays that ARE present", () => {
|
|
24
|
-
const input = {
|
|
25
|
-
apps: [{ name: "x", plugins: [] }],
|
|
26
|
-
actions: [],
|
|
27
|
-
events: [],
|
|
28
|
-
actors: [],
|
|
29
|
-
projections: [],
|
|
30
|
-
queries: [],
|
|
31
|
-
resolvers: [{ operation: "Op", version: 1 }],
|
|
32
|
-
routes: [],
|
|
33
|
-
workflows: [],
|
|
34
|
-
externalCalls: [],
|
|
35
|
-
inboundWebhooks: [],
|
|
36
|
-
outboxes: [],
|
|
37
|
-
inboxes: [],
|
|
38
|
-
crons: [],
|
|
39
|
-
hooks: [],
|
|
40
|
-
plugins: [],
|
|
41
|
-
sinks: [],
|
|
42
|
-
bindings: [],
|
|
43
|
-
graph: { events: [] },
|
|
44
|
-
generatedAt: "2026-05-17T00:00:00Z",
|
|
45
|
-
};
|
|
46
|
-
const { cache, missingFields } = normalizeCache(input);
|
|
47
|
-
expect(cache?.apps).toEqual(input.apps);
|
|
48
|
-
expect(cache?.resolvers).toEqual(input.resolvers);
|
|
49
|
-
expect(missingFields).toEqual([]);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("reports the exact list of missing array fields", () => {
|
|
53
|
-
const { missingFields } = normalizeCache({
|
|
54
|
-
apps: [],
|
|
55
|
-
actions: [],
|
|
56
|
-
events: [],
|
|
57
|
-
actors: [],
|
|
58
|
-
projections: [],
|
|
59
|
-
queries: [],
|
|
60
|
-
// resolvers + workflows + sinks + graph missing
|
|
61
|
-
routes: [],
|
|
62
|
-
externalCalls: [],
|
|
63
|
-
inboundWebhooks: [],
|
|
64
|
-
outboxes: [],
|
|
65
|
-
inboxes: [],
|
|
66
|
-
crons: [],
|
|
67
|
-
generatedAt: "2026-05-17T00:00:00Z",
|
|
68
|
-
});
|
|
69
|
-
expect([...missingFields].sort()).toEqual([
|
|
70
|
-
"bindings",
|
|
71
|
-
"graph",
|
|
72
|
-
"hooks",
|
|
73
|
-
"plugins",
|
|
74
|
-
"resolvers",
|
|
75
|
-
"sinks",
|
|
76
|
-
"workflows",
|
|
77
|
-
]);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("ignores non-array values masquerading as the field", () => {
|
|
81
|
-
const { cache, missingFields } = normalizeCache({ resolvers: "not an array" });
|
|
82
|
-
expect(cache?.resolvers).toEqual([]);
|
|
83
|
-
expect(missingFields).toContain("resolvers");
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("returns null + fatal error when input is not an object", () => {
|
|
87
|
-
expect(normalizeCache(null).cache).toBeNull();
|
|
88
|
-
expect(normalizeCache(null).fatalError).toMatch(/not a JSON object/i);
|
|
89
|
-
expect(normalizeCache([]).cache).toBeNull();
|
|
90
|
-
expect(normalizeCache("oops").cache).toBeNull();
|
|
91
|
-
expect(normalizeCache(42).cache).toBeNull();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("synthesises generatedAt when missing", () => {
|
|
95
|
-
const { cache, missingFields } = normalizeCache({});
|
|
96
|
-
expect(cache?.generatedAt).toBeTypeOf("string");
|
|
97
|
-
expect(missingFields).toContain("generatedAt");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("handles graph.events missing while graph exists", () => {
|
|
101
|
-
const { cache, missingFields } = normalizeCache({ graph: {} });
|
|
102
|
-
expect(cache?.graph).toEqual({ events: [] });
|
|
103
|
-
expect(missingFields).toContain("graph.events");
|
|
104
|
-
});
|
|
105
|
-
});
|
package/src/lib/cache.ts
DELETED
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
// Studio data layer — fetch + cache the `.nwire/manifest.json` (built by
|
|
2
|
-
// `nwire cache`). The shape mirrors `@nwire/scan`'s Cache interface but we
|
|
3
|
-
// duplicate it locally so the Studio package doesn't need to depend on
|
|
4
|
-
// scan at runtime (the manifest is just JSON).
|
|
5
|
-
|
|
6
|
-
import { ref, shallowRef, type Ref } from "vue";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* `file:line:column` of a `defineX()` call. Studio uses it to render
|
|
10
|
-
* IDE-open chips ("source" pill) next to every primitive.
|
|
11
|
-
*/
|
|
12
|
-
export interface SourceLocationEntry {
|
|
13
|
-
file: string;
|
|
14
|
-
line: number;
|
|
15
|
-
column?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ActionEntry {
|
|
19
|
-
name: string;
|
|
20
|
-
description?: string;
|
|
21
|
-
app: string;
|
|
22
|
-
/** Zod input schema serialized by the scanner. */
|
|
23
|
-
inputSchema?: unknown;
|
|
24
|
-
retry?: object;
|
|
25
|
-
policy?: string | readonly string[];
|
|
26
|
-
/** True when a handler was wired via defineAction({handler}). */
|
|
27
|
-
hasInlineHandler: boolean;
|
|
28
|
-
/** Event names this action emits, in declaration order. */
|
|
29
|
-
emits: string[];
|
|
30
|
-
/** True when the action def carries the `.public()` mark. */
|
|
31
|
-
public?: boolean;
|
|
32
|
-
persona?: string;
|
|
33
|
-
journeyStep?: string;
|
|
34
|
-
capability?: string;
|
|
35
|
-
slo?: { p95LatencyMs?: number; successRate?: number };
|
|
36
|
-
tags?: string[];
|
|
37
|
-
source?: SourceLocationEntry;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ExternalCallEntry {
|
|
41
|
-
name: string;
|
|
42
|
-
description?: string;
|
|
43
|
-
app: string;
|
|
44
|
-
target?: { provider: string; endpoint: string; region?: string };
|
|
45
|
-
request?: object;
|
|
46
|
-
response?: object;
|
|
47
|
-
hasIdempotencyKey?: boolean;
|
|
48
|
-
slo?: { p95LatencyMs?: number; successRate?: number };
|
|
49
|
-
retry?: { max: number; backoff?: string };
|
|
50
|
-
tags?: string[];
|
|
51
|
-
source?: SourceLocationEntry;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface InboundWebhookEntry {
|
|
55
|
-
name: string;
|
|
56
|
-
description?: string;
|
|
57
|
-
app: string;
|
|
58
|
-
source?: SourceLocationEntry;
|
|
59
|
-
path?: string;
|
|
60
|
-
hasSignatureVerifier?: boolean;
|
|
61
|
-
dedupe?: { window: string };
|
|
62
|
-
discriminator?: string;
|
|
63
|
-
routes?: Record<string, string>;
|
|
64
|
-
tags?: string[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface OutboxEntry {
|
|
68
|
-
name: string;
|
|
69
|
-
description?: string;
|
|
70
|
-
app: string;
|
|
71
|
-
publishes?: string[];
|
|
72
|
-
flushIntervalMs?: number;
|
|
73
|
-
maxBatch?: number;
|
|
74
|
-
tags?: string[];
|
|
75
|
-
source?: SourceLocationEntry;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface InboxEntry {
|
|
79
|
-
name: string;
|
|
80
|
-
description?: string;
|
|
81
|
-
app: string;
|
|
82
|
-
window?: string;
|
|
83
|
-
on?: string[];
|
|
84
|
-
tags?: string[];
|
|
85
|
-
source?: SourceLocationEntry;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface CronEntry {
|
|
89
|
-
name: string;
|
|
90
|
-
description?: string;
|
|
91
|
-
app: string;
|
|
92
|
-
schedule: string;
|
|
93
|
-
dispatches?: string;
|
|
94
|
-
timezone?: string;
|
|
95
|
-
tags?: string[];
|
|
96
|
-
source?: SourceLocationEntry;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Declared workflow (the unified primitive for reactions, translators, and
|
|
101
|
-
* sagas). EventStorming renders each as a synthesized policy node fanning
|
|
102
|
-
* out from its subscribed events to its declared dispatches.
|
|
103
|
-
*/
|
|
104
|
-
export interface WorkflowEntry {
|
|
105
|
-
name: string;
|
|
106
|
-
app: string;
|
|
107
|
-
description?: string;
|
|
108
|
-
/** Event names this workflow listens to. */
|
|
109
|
-
subscribesTo: string[];
|
|
110
|
-
/** Action names this workflow dispatches inside its body. */
|
|
111
|
-
dispatches: string[];
|
|
112
|
-
/** True when the workflow def carries the `.public()` mark. */
|
|
113
|
-
public?: boolean;
|
|
114
|
-
source?: SourceLocationEntry;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export interface EventEntry {
|
|
118
|
-
name: string;
|
|
119
|
-
description?: string;
|
|
120
|
-
app: string;
|
|
121
|
-
/** True when the event def carries the `.public()` mark — reaches outbound sinks. */
|
|
122
|
-
public?: boolean;
|
|
123
|
-
version?: number;
|
|
124
|
-
audience?: string[];
|
|
125
|
-
source?: SourceLocationEntry;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export interface ActorEntry {
|
|
129
|
-
name: string;
|
|
130
|
-
app: string;
|
|
131
|
-
/** State names declared in the actor's `states` map. */
|
|
132
|
-
states: string[];
|
|
133
|
-
source?: SourceLocationEntry;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export interface ProjectionEntry {
|
|
137
|
-
name: string;
|
|
138
|
-
app: string;
|
|
139
|
-
description?: string;
|
|
140
|
-
/** Event names this projection folds in. */
|
|
141
|
-
listens: string[];
|
|
142
|
-
freshness?: { p95MsBehindStream?: number };
|
|
143
|
-
source?: SourceLocationEntry;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export interface QueryEntry {
|
|
147
|
-
name: string;
|
|
148
|
-
description?: string;
|
|
149
|
-
app: string;
|
|
150
|
-
/** Backing projection name (projection-form queries only). */
|
|
151
|
-
projection?: string;
|
|
152
|
-
/** True when the query def carries the `.public()` mark. */
|
|
153
|
-
public?: boolean;
|
|
154
|
-
slo?: { p95LatencyMs?: number };
|
|
155
|
-
cacheable?: boolean;
|
|
156
|
-
source?: SourceLocationEntry;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export interface AppEntry {
|
|
160
|
-
name: string;
|
|
161
|
-
description?: string;
|
|
162
|
-
/** Plugin names installed on this app, in install order. */
|
|
163
|
-
plugins: string[];
|
|
164
|
-
tenantModel?: "single" | "per-org" | "per-account" | "per-workspace";
|
|
165
|
-
tenantKey?: string;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export interface SinkEntry {
|
|
169
|
-
name: string;
|
|
170
|
-
app: string;
|
|
171
|
-
/** Adapter kind tag — "bullmq", "nats", "capture", etc. */
|
|
172
|
-
kind?: string;
|
|
173
|
-
position: "early" | "middle" | "terminal";
|
|
174
|
-
direction: "outbound";
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export interface RouteEntry {
|
|
178
|
-
method: string;
|
|
179
|
-
path: string;
|
|
180
|
-
target?: string;
|
|
181
|
-
targetKind?: "action" | "query" | "resolver";
|
|
182
|
-
app?: string;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export interface ResolverEntry {
|
|
186
|
-
operation: string;
|
|
187
|
-
version: number;
|
|
188
|
-
status: "draft" | "active" | "deprecated" | "sunset";
|
|
189
|
-
summary?: string;
|
|
190
|
-
description?: string;
|
|
191
|
-
tags?: string[];
|
|
192
|
-
app: string;
|
|
193
|
-
bindings: Array<{
|
|
194
|
-
transport: "rest" | "graphql" | "cli";
|
|
195
|
-
method?: string;
|
|
196
|
-
path?: string;
|
|
197
|
-
}>;
|
|
198
|
-
params?: object;
|
|
199
|
-
query?: object;
|
|
200
|
-
body?: object;
|
|
201
|
-
returns: Array<{ status: number; kind: "single" | "list" | "empty" }>;
|
|
202
|
-
errors: Array<{ code: string; status: number }>;
|
|
203
|
-
successor?: { operation: string; version: number };
|
|
204
|
-
sunsetDate?: string;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* One directed edge in the static event graph as emitted by `@nwire/scan`.
|
|
209
|
-
* `from` and `to` are name identifiers (action / event / workflow /
|
|
210
|
-
* projection) per the `via` discriminator. Multiple edges share the
|
|
211
|
-
* same source or target — e.g. one event consumed by N projections
|
|
212
|
-
* shows up as N "folds" edges.
|
|
213
|
-
*/
|
|
214
|
-
export interface EventGraphEdge {
|
|
215
|
-
from: string;
|
|
216
|
-
to: string;
|
|
217
|
-
via: "emits" | "folds" | "subscribes" | "dispatches";
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export interface HookEntry {
|
|
221
|
-
id: string;
|
|
222
|
-
name: string;
|
|
223
|
-
chain: number;
|
|
224
|
-
listeners: number;
|
|
225
|
-
source?: SourceLocationEntry;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export interface PluginEntry {
|
|
229
|
-
name: string;
|
|
230
|
-
kind: "plugin" | "module";
|
|
231
|
-
app: string;
|
|
232
|
-
source?: SourceLocationEntry;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* One DI registration on an app's container — surfaced by
|
|
237
|
-
* `container.list()` and emitted by `@nwire/scan` to `.nwire/di.json`.
|
|
238
|
-
* Studio's DI page renders these as a "what's bound + who registered it"
|
|
239
|
-
* table.
|
|
240
|
-
*/
|
|
241
|
-
export interface DIBindingEntry {
|
|
242
|
-
name: string;
|
|
243
|
-
kind: "singleton" | "transient";
|
|
244
|
-
app: string;
|
|
245
|
-
source?: SourceLocationEntry;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export interface Cache {
|
|
249
|
-
generatedAt: string;
|
|
250
|
-
apps: AppEntry[];
|
|
251
|
-
actions: ActionEntry[];
|
|
252
|
-
events: EventEntry[];
|
|
253
|
-
actors: ActorEntry[];
|
|
254
|
-
projections: ProjectionEntry[];
|
|
255
|
-
queries: QueryEntry[];
|
|
256
|
-
resolvers: ResolverEntry[];
|
|
257
|
-
routes: RouteEntry[];
|
|
258
|
-
workflows: WorkflowEntry[];
|
|
259
|
-
externalCalls: ExternalCallEntry[];
|
|
260
|
-
inboundWebhooks: InboundWebhookEntry[];
|
|
261
|
-
outboxes: OutboxEntry[];
|
|
262
|
-
inboxes: InboxEntry[];
|
|
263
|
-
crons: CronEntry[];
|
|
264
|
-
hooks: HookEntry[];
|
|
265
|
-
plugins: PluginEntry[];
|
|
266
|
-
sinks: SinkEntry[];
|
|
267
|
-
bindings: DIBindingEntry[];
|
|
268
|
-
graph: { events: EventGraphEdge[] };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
import { normalizeCache } from "./normalize-cache";
|
|
272
|
-
|
|
273
|
-
const cache: Ref<Cache | null> = shallowRef(null);
|
|
274
|
-
const loading = ref(false);
|
|
275
|
-
const error: Ref<string | null> = ref(null);
|
|
276
|
-
/**
|
|
277
|
-
* Fields the cache was missing — non-fatal. Studio renders the affected
|
|
278
|
-
* pages with empty states + shows a "rebuild cache" hint to the operator.
|
|
279
|
-
*/
|
|
280
|
-
const missingFields: Ref<readonly string[]> = ref([]);
|
|
281
|
-
|
|
282
|
-
export function useCache() {
|
|
283
|
-
if (!cache.value && !loading.value) {
|
|
284
|
-
void load();
|
|
285
|
-
}
|
|
286
|
-
return { cache, loading, error, missingFields, reload: load };
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async function load(): Promise<void> {
|
|
290
|
-
loading.value = true;
|
|
291
|
-
error.value = null;
|
|
292
|
-
missingFields.value = [];
|
|
293
|
-
try {
|
|
294
|
-
const res = await fetch("/__nwire/manifest.json");
|
|
295
|
-
if (!res.ok) {
|
|
296
|
-
error.value = `Cache fetch failed (${res.status}). Run \`nwire cache\` to build it.`;
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const raw = await res.json();
|
|
300
|
-
const result = normalizeCache(raw);
|
|
301
|
-
if (result.fatalError) {
|
|
302
|
-
error.value = result.fatalError;
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
cache.value = result.cache;
|
|
306
|
-
missingFields.value = result.missingFields;
|
|
307
|
-
} catch (e) {
|
|
308
|
-
error.value = (e as Error).message;
|
|
309
|
-
} finally {
|
|
310
|
-
loading.value = false;
|
|
311
|
-
}
|
|
312
|
-
}
|