@neat.is/core 0.3.8 → 0.4.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/dist/server.cjs CHANGED
@@ -54,6 +54,7 @@ function mountBearerAuth(app, opts) {
54
54
  if (opts.trustProxy) return;
55
55
  const expected = Buffer.from(opts.token, "utf8");
56
56
  const suffixes = [...DEFAULT_UNAUTH_SUFFIXES, ...opts.extraUnauthenticatedSuffixes ?? []];
57
+ const publicRead = opts.publicRead === true;
57
58
  app.addHook("preHandler", (req, reply, done) => {
58
59
  const path38 = (req.url.split("?")[0] ?? "").replace(/\/+$/, "");
59
60
  for (const suffix of suffixes) {
@@ -62,6 +63,10 @@ function mountBearerAuth(app, opts) {
62
63
  return;
63
64
  }
64
65
  }
66
+ if (publicRead && PUBLIC_READ_METHODS.has(req.method)) {
67
+ done();
68
+ return;
69
+ }
65
70
  const header = req.headers.authorization;
66
71
  if (typeof header !== "string" || !header.startsWith("Bearer ")) {
67
72
  void reply.code(401).send({ error: "unauthorized" });
@@ -75,16 +80,21 @@ function mountBearerAuth(app, opts) {
75
80
  done();
76
81
  });
77
82
  }
83
+ function parseBoolEnv(v) {
84
+ if (!v) return false;
85
+ return v === "true" || v === "1";
86
+ }
78
87
  function readAuthEnv(env = process.env) {
79
88
  const t = env.NEAT_AUTH_TOKEN;
80
89
  const ot = env.NEAT_OTEL_TOKEN;
81
90
  return {
82
91
  authToken: t && t.length > 0 ? t : void 0,
83
92
  otelToken: ot && ot.length > 0 ? ot : t && t.length > 0 ? t : void 0,
84
- trustProxy: env.NEAT_AUTH_PROXY === "true"
93
+ trustProxy: env.NEAT_AUTH_PROXY === "true",
94
+ publicRead: parseBoolEnv(env.NEAT_PUBLIC_READ)
85
95
  };
86
96
  }
87
- var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, DEFAULT_UNAUTH_SUFFIXES;
97
+ var import_node_crypto, LOOPBACK_HOSTS, BindAuthorityError, PUBLIC_READ_METHODS, DEFAULT_UNAUTH_SUFFIXES;
88
98
  var init_auth = __esm({
89
99
  "src/auth.ts"() {
90
100
  "use strict";
@@ -104,7 +114,13 @@ var init_auth = __esm({
104
114
  this.name = "BindAuthorityError";
105
115
  }
106
116
  };
107
- DEFAULT_UNAUTH_SUFFIXES = ["/health", "/healthz", "/readyz"];
117
+ PUBLIC_READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
118
+ DEFAULT_UNAUTH_SUFFIXES = [
119
+ "/health",
120
+ "/healthz",
121
+ "/readyz",
122
+ "/api/config"
123
+ ];
108
124
  }
109
125
  });
110
126
 
@@ -307,6 +323,15 @@ function isoFromUnixNano(nanos) {
307
323
  return void 0;
308
324
  }
309
325
  }
326
+ function pickEnv(spanAttrs, resourceAttrs) {
327
+ for (const attrs of [spanAttrs, resourceAttrs]) {
328
+ for (const key of [ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT]) {
329
+ const v = attrs[key];
330
+ if (typeof v === "string" && v.length > 0) return v;
331
+ }
332
+ }
333
+ return ENV_FALLBACK;
334
+ }
310
335
  function parseOtlpRequest(body) {
311
336
  const out = [];
312
337
  for (const rs of body.resourceSpans ?? []) {
@@ -326,6 +351,7 @@ function parseOtlpRequest(body) {
326
351
  endTimeUnixNano: span.endTimeUnixNano ?? "0",
327
352
  startTimeIso: isoFromUnixNano(span.startTimeUnixNano),
328
353
  durationNanos: durationNanos(span.startTimeUnixNano, span.endTimeUnixNano),
354
+ env: pickEnv(attrs, resourceAttrs),
329
355
  attributes: attrs,
330
356
  dbSystem: typeof attrs["db.system"] === "string" ? attrs["db.system"] : void 0,
331
357
  dbName: typeof attrs["db.name"] === "string" ? attrs["db.name"] : void 0,
@@ -464,7 +490,7 @@ async function buildOtelReceiver(opts) {
464
490
  };
465
491
  return decorated;
466
492
  }
467
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
493
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, ENV_ATTR_CANONICAL, ENV_ATTR_COMPAT, ENV_FALLBACK, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
468
494
  var init_otel = __esm({
469
495
  "src/otel.ts"() {
470
496
  "use strict";
@@ -474,6 +500,9 @@ var init_otel = __esm({
474
500
  import_fastify2 = __toESM(require("fastify"), 1);
475
501
  import_protobufjs = __toESM(require("protobufjs"), 1);
476
502
  init_auth();
503
+ ENV_ATTR_CANONICAL = "deployment.environment.name";
504
+ ENV_ATTR_COMPAT = "deployment.environment";
505
+ ENV_FALLBACK = "unknown";
477
506
  exportTraceServiceRequestType = null;
478
507
  exportTraceServiceResponseType = null;
479
508
  cachedProtobufResponseBody = null;
@@ -506,7 +535,7 @@ function getGraph(project = DEFAULT_PROJECT) {
506
535
  init_cjs_shims();
507
536
  var import_fastify = __toESM(require("fastify"), 1);
508
537
  var import_cors = __toESM(require("@fastify/cors"), 1);
509
- var import_types21 = require("@neat.is/types");
538
+ var import_types22 = require("@neat.is/types");
510
539
 
511
540
  // src/divergences.ts
512
541
  init_cjs_shims();
@@ -1763,52 +1792,64 @@ function cacheSpanService(span, now) {
1763
1792
  if (!span.traceId || !span.spanId) return;
1764
1793
  const key = parentSpanKey(span.traceId, span.spanId);
1765
1794
  parentSpanCache.delete(key);
1766
- parentSpanCache.set(key, { service: span.service, expiresAt: now + PARENT_SPAN_CACHE_TTL_MS });
1795
+ parentSpanCache.set(key, {
1796
+ service: span.service,
1797
+ env: span.env ?? "unknown",
1798
+ expiresAt: now + PARENT_SPAN_CACHE_TTL_MS
1799
+ });
1767
1800
  while (parentSpanCache.size > PARENT_SPAN_CACHE_SIZE) {
1768
1801
  const oldest = parentSpanCache.keys().next().value;
1769
1802
  if (!oldest) break;
1770
1803
  parentSpanCache.delete(oldest);
1771
1804
  }
1772
1805
  }
1773
- function lookupParentSpanService(traceId, parentSpanId, now) {
1806
+ function lookupParentSpan(traceId, parentSpanId, now) {
1774
1807
  const entry = parentSpanCache.get(parentSpanKey(traceId, parentSpanId));
1775
1808
  if (!entry) return null;
1776
1809
  if (entry.expiresAt <= now) {
1777
1810
  parentSpanCache.delete(parentSpanKey(traceId, parentSpanId));
1778
1811
  return null;
1779
1812
  }
1780
- return entry.service;
1813
+ return { service: entry.service, env: entry.env };
1781
1814
  }
1782
- function resolveServiceId(graph, host) {
1783
- const direct = (0, import_types4.serviceId)(host);
1784
- if (graph.hasNode(direct)) return direct;
1785
- let found = null;
1815
+ function resolveServiceId(graph, host, env) {
1816
+ const envTagged = (0, import_types4.serviceId)(host, env);
1817
+ if (graph.hasNode(envTagged)) return envTagged;
1818
+ const envLess = (0, import_types4.serviceId)(host);
1819
+ if (envLess !== envTagged && graph.hasNode(envLess)) return envLess;
1820
+ let sameEnv = null;
1821
+ let envLessMatch = null;
1822
+ let anyMatch = null;
1786
1823
  graph.forEachNode((id, attrs) => {
1787
- if (found) return;
1824
+ if (sameEnv) return;
1788
1825
  const a = attrs;
1789
1826
  if (a.type !== import_types4.NodeType.ServiceNode) return;
1790
- if (a.name === host) {
1791
- found = id;
1827
+ const matchesByName = a.name === host;
1828
+ const matchesByAlias = a.aliases ? a.aliases.includes(host) : false;
1829
+ if (!matchesByName && !matchesByAlias) return;
1830
+ const nodeEnv = a.env ?? "unknown";
1831
+ if (nodeEnv === env) {
1832
+ sameEnv = id;
1792
1833
  return;
1793
1834
  }
1794
- if (a.aliases && a.aliases.includes(host)) {
1795
- found = id;
1796
- }
1835
+ if (nodeEnv === "unknown" && !envLessMatch) envLessMatch = id;
1836
+ else if (!anyMatch) anyMatch = id;
1797
1837
  });
1798
- return found;
1838
+ return sameEnv ?? envLessMatch ?? anyMatch;
1799
1839
  }
1800
1840
  function frontierIdFor(host) {
1801
1841
  return (0, import_types4.frontierId)(host);
1802
1842
  }
1803
- function ensureServiceNode(graph, serviceName) {
1804
- const id = (0, import_types4.serviceId)(serviceName);
1843
+ function ensureServiceNode(graph, serviceName, env) {
1844
+ const id = (0, import_types4.serviceId)(serviceName, env);
1805
1845
  if (graph.hasNode(id)) return id;
1806
1846
  const node = {
1807
1847
  id,
1808
1848
  type: import_types4.NodeType.ServiceNode,
1809
1849
  name: serviceName,
1810
1850
  language: "unknown",
1811
- discoveredVia: "otel"
1851
+ discoveredVia: "otel",
1852
+ ...env !== "unknown" ? { env } : {}
1812
1853
  };
1813
1854
  graph.addNode(id, node);
1814
1855
  return id;
@@ -1943,7 +1984,8 @@ function sanitizeAttributes(attrs) {
1943
1984
  async function handleSpan(ctx, span) {
1944
1985
  const ts = span.startTimeIso ?? nowIso(ctx);
1945
1986
  const nowMs = ctx.now ? ctx.now() : Date.now();
1946
- const sourceId = ensureServiceNode(ctx.graph, span.service);
1987
+ const env = span.env ?? "unknown";
1988
+ const sourceId = ensureServiceNode(ctx.graph, span.service, env);
1947
1989
  const isError = span.statusCode === 2;
1948
1990
  cacheSpanService(span, nowMs);
1949
1991
  let affectedNode = sourceId;
@@ -1966,7 +2008,7 @@ async function handleSpan(ctx, span) {
1966
2008
  const host = pickAddress(span);
1967
2009
  let resolvedViaAddress = false;
1968
2010
  if (host && host !== span.service) {
1969
- const targetId = resolveServiceId(ctx.graph, host);
2011
+ const targetId = resolveServiceId(ctx.graph, host, env);
1970
2012
  if (targetId && targetId !== sourceId) {
1971
2013
  upsertObservedEdge(
1972
2014
  ctx.graph,
@@ -1993,9 +2035,9 @@ async function handleSpan(ctx, span) {
1993
2035
  }
1994
2036
  }
1995
2037
  if (!resolvedViaAddress && span.parentSpanId) {
1996
- const parentService = lookupParentSpanService(span.traceId, span.parentSpanId, nowMs);
1997
- if (parentService && parentService !== span.service) {
1998
- const parentId = ensureServiceNode(ctx.graph, parentService);
2038
+ const parent = lookupParentSpan(span.traceId, span.parentSpanId, nowMs);
2039
+ if (parent && parent.service !== span.service) {
2040
+ const parentId = ensureServiceNode(ctx.graph, parent.service, parent.env);
1999
2041
  upsertObservedEdge(
2000
2042
  ctx.graph,
2001
2043
  import_types4.EdgeType.CALLS,
@@ -2191,6 +2233,28 @@ async function readErrorEvents(errorsPath) {
2191
2233
  throw err;
2192
2234
  }
2193
2235
  }
2236
+ function mergeSnapshot(graph, snapshot) {
2237
+ const exported = snapshot.graph;
2238
+ let nodesAdded = 0;
2239
+ let edgesAdded = 0;
2240
+ for (const node of exported.nodes ?? []) {
2241
+ if (graph.hasNode(node.key)) continue;
2242
+ if (!node.attributes) continue;
2243
+ graph.addNode(node.key, node.attributes);
2244
+ nodesAdded++;
2245
+ }
2246
+ for (const edge of exported.edges ?? []) {
2247
+ const attrs = edge.attributes;
2248
+ if (!attrs) continue;
2249
+ const id = edge.key ?? attrs.id;
2250
+ if (!id) continue;
2251
+ if (graph.hasEdge(id)) continue;
2252
+ if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
2253
+ graph.addEdgeWithKey(id, edge.source, edge.target, attrs);
2254
+ edgesAdded++;
2255
+ }
2256
+ return { nodesAdded, edgesAdded };
2257
+ }
2194
2258
 
2195
2259
  // src/extract/services.ts
2196
2260
  init_cjs_shims();
@@ -2214,8 +2278,36 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
2214
2278
  ".turbo",
2215
2279
  "dist",
2216
2280
  "build",
2217
- ".next"
2281
+ ".next",
2282
+ // Python virtualenv shapes (issue #344). Walking into a venv pulls in the
2283
+ // entire CPython stdlib + every installed package as if it were first-party
2284
+ // service code — 20k+ files, none of which the user wrote. The shape names
2285
+ // cover the three common venv tools (`venv` / `python -m venv`,
2286
+ // `virtualenv`) and the tox + PEP 582 conventions.
2287
+ ".venv",
2288
+ "venv",
2289
+ "__pypackages__",
2290
+ ".tox",
2291
+ // `site-packages` shows up nested inside venvs that don't carry one of the
2292
+ // outer names (system Python on macOS, in-place pyenv layouts). Listing it
2293
+ // here means we stop the walk at the boundary even when the wrapper dir
2294
+ // wasn't recognisable.
2295
+ "site-packages"
2218
2296
  ]);
2297
+ var PYVENV_MARKER_CACHE = /* @__PURE__ */ new Map();
2298
+ async function isPythonVenvDir(dir) {
2299
+ const cached = PYVENV_MARKER_CACHE.get(dir);
2300
+ if (cached !== void 0) return cached;
2301
+ try {
2302
+ const stat = await import_node_fs4.promises.stat(import_node_path4.default.join(dir, "pyvenv.cfg"));
2303
+ const ok = stat.isFile();
2304
+ PYVENV_MARKER_CACHE.set(dir, ok);
2305
+ return ok;
2306
+ } catch {
2307
+ PYVENV_MARKER_CACHE.set(dir, false);
2308
+ return false;
2309
+ }
2310
+ }
2219
2311
  function isConfigFile(name) {
2220
2312
  const ext = import_node_path4.default.extname(name);
2221
2313
  if (CONFIG_FILE_EXTENSIONS.has(ext)) return { match: true, fileType: ext.slice(1) };
@@ -2552,6 +2644,7 @@ async function walkDirs(start, scanPath, options, visit) {
2552
2644
  const rel = import_node_path8.default.relative(scanPath, child).split(import_node_path8.default.sep).join("/");
2553
2645
  if (rel && options.ig.ignores(rel + "/")) continue;
2554
2646
  }
2647
+ if (await isPythonVenvDir(child)) continue;
2555
2648
  await visit(child);
2556
2649
  await recurse(child, depth + 1);
2557
2650
  }
@@ -2587,6 +2680,18 @@ async function expandWorkspaceGlobs(scanPath, globs) {
2587
2680
  }
2588
2681
  return [...found];
2589
2682
  }
2683
+ function detectJsFramework(pkg) {
2684
+ const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
2685
+ if (deps["next"] !== void 0) return "next";
2686
+ if (deps["remix"] !== void 0) return "remix";
2687
+ for (const k of Object.keys(deps)) {
2688
+ if (k.startsWith("@remix-run/")) return "remix";
2689
+ }
2690
+ if (deps["@sveltejs/kit"] !== void 0) return "sveltekit";
2691
+ if (deps["nuxt"] !== void 0) return "nuxt";
2692
+ if (deps["astro"] !== void 0) return "astro";
2693
+ return void 0;
2694
+ }
2590
2695
  async function discoverNodeService(scanPath, dir) {
2591
2696
  const pkgPath = import_node_path8.default.join(dir, "package.json");
2592
2697
  if (!await exists(pkgPath)) return null;
@@ -2598,6 +2703,7 @@ async function discoverNodeService(scanPath, dir) {
2598
2703
  return null;
2599
2704
  }
2600
2705
  if (!pkg.name) return null;
2706
+ const framework = detectJsFramework(pkg);
2601
2707
  const node = {
2602
2708
  id: (0, import_types6.serviceId)(pkg.name),
2603
2709
  type: import_types6.NodeType.ServiceNode,
@@ -2606,7 +2712,8 @@ async function discoverNodeService(scanPath, dir) {
2606
2712
  version: pkg.version,
2607
2713
  dependencies: pkg.dependencies ?? {},
2608
2714
  repoPath: import_node_path8.default.relative(scanPath, dir),
2609
- ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {}
2715
+ ...pkg.engines?.node ? { nodeEngine: pkg.engines.node } : {},
2716
+ ...framework ? { framework } : {}
2610
2717
  };
2611
2718
  return { pkg, dir, node };
2612
2719
  }
@@ -2816,7 +2923,9 @@ async function walkYamlFiles(start, depth = 0, max = 5) {
2816
2923
  for (const entry of entries) {
2817
2924
  if (entry.isDirectory()) {
2818
2925
  if (IGNORED_DIRS.has(entry.name)) continue;
2819
- out.push(...await walkYamlFiles(import_node_path9.default.join(start, entry.name), depth + 1, max));
2926
+ const child = import_node_path9.default.join(start, entry.name);
2927
+ if (await isPythonVenvDir(child)) continue;
2928
+ out.push(...await walkYamlFiles(child, depth + 1, max));
2820
2929
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path9.default.extname(entry.name))) {
2821
2930
  out.push(import_node_path9.default.join(start, entry.name));
2822
2931
  }
@@ -3498,7 +3607,9 @@ async function walkConfigFiles(dir) {
3498
3607
  for (const entry of entries) {
3499
3608
  const full = import_node_path18.default.join(current, entry.name);
3500
3609
  if (entry.isDirectory()) {
3501
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
3610
+ if (IGNORED_DIRS.has(entry.name)) continue;
3611
+ if (await isPythonVenvDir(full)) continue;
3612
+ await walk(full);
3502
3613
  } else if (entry.isFile() && isConfigFile(entry.name).match) {
3503
3614
  out.push(full);
3504
3615
  }
@@ -3566,7 +3677,9 @@ async function walkSourceFiles(dir) {
3566
3677
  for (const entry of entries) {
3567
3678
  const full = import_node_path19.default.join(current, entry.name);
3568
3679
  if (entry.isDirectory()) {
3569
- if (!IGNORED_DIRS.has(entry.name)) await walk(full);
3680
+ if (IGNORED_DIRS.has(entry.name)) continue;
3681
+ if (await isPythonVenvDir(full)) continue;
3682
+ await walk(full);
3570
3683
  } else if (entry.isFile() && SERVICE_FILE_EXTENSIONS.has(import_node_path19.default.extname(entry.name))) {
3571
3684
  out.push(full);
3572
3685
  }
@@ -4202,7 +4315,9 @@ async function walkTfFiles(start, depth = 0, max = 5) {
4202
4315
  for (const entry of entries) {
4203
4316
  if (entry.isDirectory()) {
4204
4317
  if (IGNORED_DIRS.has(entry.name) || entry.name === ".terraform") continue;
4205
- out.push(...await walkTfFiles(import_node_path27.default.join(start, entry.name), depth + 1, max));
4318
+ const child = import_node_path27.default.join(start, entry.name);
4319
+ if (await isPythonVenvDir(child)) continue;
4320
+ out.push(...await walkTfFiles(child, depth + 1, max));
4206
4321
  } else if (entry.isFile() && entry.name.endsWith(".tf")) {
4207
4322
  out.push(import_node_path27.default.join(start, entry.name));
4208
4323
  }
@@ -4250,7 +4365,9 @@ async function walkYamlFiles2(start, depth = 0, max = 5) {
4250
4365
  for (const entry of entries) {
4251
4366
  if (entry.isDirectory()) {
4252
4367
  if (IGNORED_DIRS.has(entry.name)) continue;
4253
- out.push(...await walkYamlFiles2(import_node_path28.default.join(start, entry.name), depth + 1, max));
4368
+ const child = import_node_path28.default.join(start, entry.name);
4369
+ if (await isPythonVenvDir(child)) continue;
4370
+ out.push(...await walkYamlFiles2(child, depth + 1, max));
4254
4371
  } else if (entry.isFile() && CONFIG_FILE_EXTENSIONS.has(import_node_path28.default.extname(entry.name))) {
4255
4372
  out.push(import_node_path28.default.join(start, entry.name));
4256
4373
  }
@@ -4461,25 +4578,138 @@ function canonicalJson(value) {
4461
4578
  });
4462
4579
  }
4463
4580
 
4464
- // src/projects.ts
4581
+ // src/persist.ts
4465
4582
  init_cjs_shims();
4583
+ var import_node_fs19 = require("fs");
4466
4584
  var import_node_path31 = __toESM(require("path"), 1);
4585
+ var import_types20 = require("@neat.is/types");
4586
+ var SCHEMA_VERSION = 4;
4587
+ function migrateV1ToV2(payload) {
4588
+ const nodes = payload.graph.nodes;
4589
+ if (Array.isArray(nodes)) {
4590
+ for (const node of nodes) {
4591
+ if (node.attributes && "pgDriverVersion" in node.attributes) {
4592
+ delete node.attributes.pgDriverVersion;
4593
+ }
4594
+ }
4595
+ }
4596
+ return { ...payload, schemaVersion: 2 };
4597
+ }
4598
+ function migrateV3ToV4(payload) {
4599
+ return { ...payload, schemaVersion: 4 };
4600
+ }
4601
+ function migrateV2ToV3(payload) {
4602
+ const edges = payload.graph.edges;
4603
+ if (Array.isArray(edges)) {
4604
+ for (const edge of edges) {
4605
+ const attrs = edge.attributes;
4606
+ if (!attrs || attrs.provenance !== "FRONTIER") continue;
4607
+ attrs.provenance = import_types20.Provenance.OBSERVED;
4608
+ const type = typeof attrs.type === "string" ? attrs.type : void 0;
4609
+ const source = typeof attrs.source === "string" ? attrs.source : void 0;
4610
+ const target = typeof attrs.target === "string" ? attrs.target : void 0;
4611
+ if (type && source && target) {
4612
+ const newId = (0, import_types20.observedEdgeId)(source, target, type);
4613
+ attrs.id = newId;
4614
+ if (edge.key) edge.key = newId;
4615
+ }
4616
+ }
4617
+ }
4618
+ return { ...payload, schemaVersion: 3 };
4619
+ }
4620
+ async function ensureDir(filePath) {
4621
+ await import_node_fs19.promises.mkdir(import_node_path31.default.dirname(filePath), { recursive: true });
4622
+ }
4623
+ async function saveGraphToDisk(graph, outPath) {
4624
+ await ensureDir(outPath);
4625
+ const payload = {
4626
+ schemaVersion: SCHEMA_VERSION,
4627
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
4628
+ graph: graph.export()
4629
+ };
4630
+ const tmp = `${outPath}.tmp`;
4631
+ await import_node_fs19.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
4632
+ await import_node_fs19.promises.rename(tmp, outPath);
4633
+ }
4634
+ async function loadGraphFromDisk(graph, outPath) {
4635
+ let raw;
4636
+ try {
4637
+ raw = await import_node_fs19.promises.readFile(outPath, "utf8");
4638
+ } catch (err) {
4639
+ if (err.code === "ENOENT") return;
4640
+ throw err;
4641
+ }
4642
+ let payload = JSON.parse(raw);
4643
+ if (payload.schemaVersion === 1) {
4644
+ payload = migrateV1ToV2(payload);
4645
+ }
4646
+ if (payload.schemaVersion === 2) {
4647
+ payload = migrateV2ToV3(payload);
4648
+ }
4649
+ if (payload.schemaVersion === 3) {
4650
+ payload = migrateV3ToV4(payload);
4651
+ }
4652
+ if (payload.schemaVersion !== SCHEMA_VERSION) {
4653
+ throw new Error(
4654
+ `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
4655
+ );
4656
+ }
4657
+ graph.clear();
4658
+ graph.import(payload.graph);
4659
+ }
4660
+ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
4661
+ let stopped = false;
4662
+ const tick = async () => {
4663
+ if (stopped) return;
4664
+ try {
4665
+ await saveGraphToDisk(graph, outPath);
4666
+ } catch (err) {
4667
+ console.error("persist: periodic save failed", err);
4668
+ }
4669
+ };
4670
+ const interval = setInterval(() => {
4671
+ void tick();
4672
+ }, intervalMs);
4673
+ const onSignal = (signal) => {
4674
+ void (async () => {
4675
+ try {
4676
+ await saveGraphToDisk(graph, outPath);
4677
+ } catch (err) {
4678
+ console.error(`persist: ${signal} save failed`, err);
4679
+ } finally {
4680
+ process.exit(0);
4681
+ }
4682
+ })();
4683
+ };
4684
+ process.on("SIGTERM", onSignal);
4685
+ process.on("SIGINT", onSignal);
4686
+ return () => {
4687
+ stopped = true;
4688
+ clearInterval(interval);
4689
+ process.off("SIGTERM", onSignal);
4690
+ process.off("SIGINT", onSignal);
4691
+ };
4692
+ }
4693
+
4694
+ // src/projects.ts
4695
+ init_cjs_shims();
4696
+ var import_node_path32 = __toESM(require("path"), 1);
4467
4697
  function pathsForProject(project, baseDir) {
4468
4698
  if (project === DEFAULT_PROJECT) {
4469
4699
  return {
4470
- snapshotPath: import_node_path31.default.join(baseDir, "graph.json"),
4471
- errorsPath: import_node_path31.default.join(baseDir, "errors.ndjson"),
4472
- staleEventsPath: import_node_path31.default.join(baseDir, "stale-events.ndjson"),
4473
- embeddingsCachePath: import_node_path31.default.join(baseDir, "embeddings.json"),
4474
- policyViolationsPath: import_node_path31.default.join(baseDir, "policy-violations.ndjson")
4700
+ snapshotPath: import_node_path32.default.join(baseDir, "graph.json"),
4701
+ errorsPath: import_node_path32.default.join(baseDir, "errors.ndjson"),
4702
+ staleEventsPath: import_node_path32.default.join(baseDir, "stale-events.ndjson"),
4703
+ embeddingsCachePath: import_node_path32.default.join(baseDir, "embeddings.json"),
4704
+ policyViolationsPath: import_node_path32.default.join(baseDir, "policy-violations.ndjson")
4475
4705
  };
4476
4706
  }
4477
4707
  return {
4478
- snapshotPath: import_node_path31.default.join(baseDir, `${project}.json`),
4479
- errorsPath: import_node_path31.default.join(baseDir, `errors.${project}.ndjson`),
4480
- staleEventsPath: import_node_path31.default.join(baseDir, `stale-events.${project}.ndjson`),
4481
- embeddingsCachePath: import_node_path31.default.join(baseDir, `embeddings.${project}.json`),
4482
- policyViolationsPath: import_node_path31.default.join(baseDir, `policy-violations.${project}.ndjson`)
4708
+ snapshotPath: import_node_path32.default.join(baseDir, `${project}.json`),
4709
+ errorsPath: import_node_path32.default.join(baseDir, `errors.${project}.ndjson`),
4710
+ staleEventsPath: import_node_path32.default.join(baseDir, `stale-events.${project}.ndjson`),
4711
+ embeddingsCachePath: import_node_path32.default.join(baseDir, `embeddings.${project}.json`),
4712
+ policyViolationsPath: import_node_path32.default.join(baseDir, `policy-violations.${project}.ndjson`)
4483
4713
  };
4484
4714
  }
4485
4715
  var Projects = class {
@@ -4519,23 +4749,23 @@ function parseExtraProjects(raw) {
4519
4749
 
4520
4750
  // src/registry.ts
4521
4751
  init_cjs_shims();
4522
- var import_node_fs19 = require("fs");
4752
+ var import_node_fs20 = require("fs");
4523
4753
  var import_node_os2 = __toESM(require("os"), 1);
4524
- var import_node_path32 = __toESM(require("path"), 1);
4525
- var import_types20 = require("@neat.is/types");
4754
+ var import_node_path33 = __toESM(require("path"), 1);
4755
+ var import_types21 = require("@neat.is/types");
4526
4756
  function neatHome() {
4527
4757
  const override = process.env.NEAT_HOME;
4528
- if (override && override.length > 0) return import_node_path32.default.resolve(override);
4529
- return import_node_path32.default.join(import_node_os2.default.homedir(), ".neat");
4758
+ if (override && override.length > 0) return import_node_path33.default.resolve(override);
4759
+ return import_node_path33.default.join(import_node_os2.default.homedir(), ".neat");
4530
4760
  }
4531
4761
  function registryPath() {
4532
- return import_node_path32.default.join(neatHome(), "projects.json");
4762
+ return import_node_path33.default.join(neatHome(), "projects.json");
4533
4763
  }
4534
4764
  async function readRegistry() {
4535
4765
  const file = registryPath();
4536
4766
  let raw;
4537
4767
  try {
4538
- raw = await import_node_fs19.promises.readFile(file, "utf8");
4768
+ raw = await import_node_fs20.promises.readFile(file, "utf8");
4539
4769
  } catch (err) {
4540
4770
  if (err.code === "ENOENT") {
4541
4771
  return { version: 1, projects: [] };
@@ -4543,7 +4773,7 @@ async function readRegistry() {
4543
4773
  throw err;
4544
4774
  }
4545
4775
  const parsed = JSON.parse(raw);
4546
- return import_types20.RegistryFileSchema.parse(parsed);
4776
+ return import_types21.RegistryFileSchema.parse(parsed);
4547
4777
  }
4548
4778
  async function getProject(name) {
4549
4779
  const reg = await readRegistry();
@@ -4626,10 +4856,19 @@ function projectFromReq(req) {
4626
4856
  const params = req.params;
4627
4857
  return params.project ?? DEFAULT_PROJECT;
4628
4858
  }
4629
- function resolveProject(registry, req, reply) {
4859
+ function resolveProject(registry, req, reply, bootstrap) {
4630
4860
  const name = projectFromReq(req);
4631
4861
  const ctx = registry.get(name);
4632
4862
  if (!ctx) {
4863
+ const phase = bootstrap?.status(name);
4864
+ if (phase === "bootstrapping") {
4865
+ void reply.code(503).send({ ready: false, project: name, status: "bootstrapping" });
4866
+ return null;
4867
+ }
4868
+ if (phase === "broken") {
4869
+ void reply.code(503).send({ ready: false, project: name, status: "broken" });
4870
+ return null;
4871
+ }
4633
4872
  void reply.code(404).send({ error: "project not found", project: name });
4634
4873
  return null;
4635
4874
  }
@@ -4659,36 +4898,38 @@ function buildLegacyRegistry(opts) {
4659
4898
  function registerRoutes(scope, ctx) {
4660
4899
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
4661
4900
  scope.get("/events", (req, reply) => {
4662
- const proj = resolveProject(registry, req, reply);
4901
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4663
4902
  if (!proj) return;
4664
4903
  handleSse(req, reply, { project: proj.name });
4665
4904
  });
4666
- scope.get("/health", async (req, reply) => {
4667
- const proj = resolveProject(registry, req, reply);
4668
- if (!proj) return;
4669
- const uptimeMs = Date.now() - startedAt;
4670
- return {
4671
- ok: true,
4672
- project: proj.name,
4673
- uptimeMs,
4674
- // Legacy fields kept additively. The web shell's StatusBar reads
4675
- // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4676
- // the canonical triple and lets the extras pass through.
4677
- uptime: Math.floor(uptimeMs / 1e3),
4678
- nodeCount: proj.graph.order,
4679
- edgeCount: proj.graph.size,
4680
- lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4681
- };
4682
- });
4905
+ if (ctx.scope === "project") {
4906
+ scope.get("/health", async (req, reply) => {
4907
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4908
+ if (!proj) return;
4909
+ const uptimeMs = Date.now() - startedAt;
4910
+ return {
4911
+ ok: true,
4912
+ project: proj.name,
4913
+ uptimeMs,
4914
+ // Legacy fields kept additively. The web shell's StatusBar reads
4915
+ // nodeCount / edgeCount; ADR-061's HealthResponseSchema validates
4916
+ // the canonical triple and lets the extras pass through.
4917
+ uptime: Math.floor(uptimeMs / 1e3),
4918
+ nodeCount: proj.graph.order,
4919
+ edgeCount: proj.graph.size,
4920
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString()
4921
+ };
4922
+ });
4923
+ }
4683
4924
  scope.get("/graph", async (req, reply) => {
4684
- const proj = resolveProject(registry, req, reply);
4925
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4685
4926
  if (!proj) return;
4686
4927
  return serializeGraph(proj.graph);
4687
4928
  });
4688
4929
  scope.get(
4689
4930
  "/graph/node/:id",
4690
4931
  async (req, reply) => {
4691
- const proj = resolveProject(registry, req, reply);
4932
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4692
4933
  if (!proj) return;
4693
4934
  const { id } = req.params;
4694
4935
  if (!proj.graph.hasNode(id)) {
@@ -4700,7 +4941,7 @@ function registerRoutes(scope, ctx) {
4700
4941
  scope.get(
4701
4942
  "/graph/edges/:id",
4702
4943
  async (req, reply) => {
4703
- const proj = resolveProject(registry, req, reply);
4944
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4704
4945
  if (!proj) return;
4705
4946
  const { id } = req.params;
4706
4947
  if (!proj.graph.hasNode(id)) {
@@ -4712,7 +4953,7 @@ function registerRoutes(scope, ctx) {
4712
4953
  }
4713
4954
  );
4714
4955
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
4715
- const proj = resolveProject(registry, req, reply);
4956
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4716
4957
  if (!proj) return;
4717
4958
  const { nodeId } = req.params;
4718
4959
  if (!proj.graph.hasNode(nodeId)) {
@@ -4727,18 +4968,18 @@ function registerRoutes(scope, ctx) {
4727
4968
  return getTransitiveDependencies(proj.graph, nodeId, depth);
4728
4969
  });
4729
4970
  scope.get("/graph/divergences", async (req, reply) => {
4730
- const proj = resolveProject(registry, req, reply);
4971
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4731
4972
  if (!proj) return;
4732
4973
  let typeFilter;
4733
4974
  if (req.query.type) {
4734
4975
  const candidates = req.query.type.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
4735
4976
  const parsed = [];
4736
4977
  for (const c of candidates) {
4737
- const r = import_types21.DivergenceTypeSchema.safeParse(c);
4978
+ const r = import_types22.DivergenceTypeSchema.safeParse(c);
4738
4979
  if (!r.success) {
4739
4980
  return reply.code(400).send({
4740
4981
  error: `unknown divergence type "${c}"`,
4741
- allowed: import_types21.DivergenceTypeSchema.options
4982
+ allowed: import_types22.DivergenceTypeSchema.options
4742
4983
  });
4743
4984
  }
4744
4985
  parsed.push(r.data);
@@ -4762,7 +5003,7 @@ function registerRoutes(scope, ctx) {
4762
5003
  });
4763
5004
  });
4764
5005
  scope.get("/incidents", async (req, reply) => {
4765
- const proj = resolveProject(registry, req, reply);
5006
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4766
5007
  if (!proj) return;
4767
5008
  const epath = errorsPathFor(proj);
4768
5009
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -4774,7 +5015,7 @@ function registerRoutes(scope, ctx) {
4774
5015
  return { count: sliced.length, total, events: sliced };
4775
5016
  });
4776
5017
  scope.get("/stale-events", async (req, reply) => {
4777
- const proj = resolveProject(registry, req, reply);
5018
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4778
5019
  if (!proj) return;
4779
5020
  const spath = staleEventsPathFor(proj);
4780
5021
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -4789,7 +5030,7 @@ function registerRoutes(scope, ctx) {
4789
5030
  scope.get(
4790
5031
  "/incidents/:nodeId",
4791
5032
  async (req, reply) => {
4792
- const proj = resolveProject(registry, req, reply);
5033
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4793
5034
  if (!proj) return;
4794
5035
  const { nodeId } = req.params;
4795
5036
  if (!proj.graph.hasNode(nodeId)) {
@@ -4805,7 +5046,7 @@ function registerRoutes(scope, ctx) {
4805
5046
  }
4806
5047
  );
4807
5048
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
4808
- const proj = resolveProject(registry, req, reply);
5049
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4809
5050
  if (!proj) return;
4810
5051
  const { nodeId } = req.params;
4811
5052
  if (!proj.graph.hasNode(nodeId)) {
@@ -4825,7 +5066,7 @@ function registerRoutes(scope, ctx) {
4825
5066
  return result;
4826
5067
  });
4827
5068
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
4828
- const proj = resolveProject(registry, req, reply);
5069
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4829
5070
  if (!proj) return;
4830
5071
  const { nodeId } = req.params;
4831
5072
  if (!proj.graph.hasNode(nodeId)) {
@@ -4838,7 +5079,7 @@ function registerRoutes(scope, ctx) {
4838
5079
  return getBlastRadius(proj.graph, nodeId, depth);
4839
5080
  });
4840
5081
  scope.get("/search", async (req, reply) => {
4841
- const proj = resolveProject(registry, req, reply);
5082
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4842
5083
  if (!proj) return;
4843
5084
  const raw = (req.query.q ?? "").trim();
4844
5085
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -4869,7 +5110,7 @@ function registerRoutes(scope, ctx) {
4869
5110
  scope.get(
4870
5111
  "/graph/diff",
4871
5112
  async (req, reply) => {
4872
- const proj = resolveProject(registry, req, reply);
5113
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4873
5114
  if (!proj) return;
4874
5115
  const against = req.query.against;
4875
5116
  if (!against) {
@@ -4883,8 +5124,37 @@ function registerRoutes(scope, ctx) {
4883
5124
  }
4884
5125
  }
4885
5126
  );
5127
+ scope.post("/snapshot", async (req, reply) => {
5128
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
5129
+ if (!proj) return;
5130
+ const body = req.body;
5131
+ if (!body || typeof body !== "object" || !body.snapshot) {
5132
+ return reply.code(400).send({ error: "request body must be { snapshot: <persisted-graph> }" });
5133
+ }
5134
+ const snap = body.snapshot;
5135
+ if (typeof snap.schemaVersion !== "number" || snap.schemaVersion !== SCHEMA_VERSION) {
5136
+ return reply.code(400).send({
5137
+ error: `unsupported snapshot schemaVersion ${snap.schemaVersion} (expected ${SCHEMA_VERSION})`
5138
+ });
5139
+ }
5140
+ try {
5141
+ const result = mergeSnapshot(proj.graph, snap);
5142
+ return {
5143
+ project: proj.name,
5144
+ nodesAdded: result.nodesAdded,
5145
+ edgesAdded: result.edgesAdded,
5146
+ nodeCount: proj.graph.order,
5147
+ edgeCount: proj.graph.size
5148
+ };
5149
+ } catch (err) {
5150
+ return reply.code(400).send({
5151
+ error: "snapshot merge failed",
5152
+ details: err.message
5153
+ });
5154
+ }
5155
+ });
4886
5156
  scope.post("/graph/scan", async (req, reply) => {
4887
- const proj = resolveProject(registry, req, reply);
5157
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4888
5158
  if (!proj) return;
4889
5159
  if (!proj.scanPath) {
4890
5160
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -4900,7 +5170,7 @@ function registerRoutes(scope, ctx) {
4900
5170
  };
4901
5171
  });
4902
5172
  scope.get("/policies", async (req, reply) => {
4903
- const proj = resolveProject(registry, req, reply);
5173
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4904
5174
  if (!proj) return;
4905
5175
  const policyPath = ctx.policyFilePathFor(proj);
4906
5176
  if (!policyPath) {
@@ -4917,12 +5187,12 @@ function registerRoutes(scope, ctx) {
4917
5187
  }
4918
5188
  });
4919
5189
  scope.get("/policies/violations", async (req, reply) => {
4920
- const proj = resolveProject(registry, req, reply);
5190
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4921
5191
  if (!proj) return;
4922
5192
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
4923
5193
  let violations = await log.readAll();
4924
5194
  if (req.query.severity) {
4925
- const sev = import_types21.PolicySeveritySchema.safeParse(req.query.severity);
5195
+ const sev = import_types22.PolicySeveritySchema.safeParse(req.query.severity);
4926
5196
  if (!sev.success) {
4927
5197
  return reply.code(400).send({
4928
5198
  error: "invalid severity",
@@ -4937,9 +5207,9 @@ function registerRoutes(scope, ctx) {
4937
5207
  return { violations };
4938
5208
  });
4939
5209
  scope.post("/policies/check", async (req, reply) => {
4940
- const proj = resolveProject(registry, req, reply);
5210
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap);
4941
5211
  if (!proj) return;
4942
- const parsed = import_types21.PoliciesCheckBodySchema.safeParse(req.body ?? {});
5212
+ const parsed = import_types22.PoliciesCheckBodySchema.safeParse(req.body ?? {});
4943
5213
  if (!parsed.success) {
4944
5214
  return reply.code(400).send({
4945
5215
  error: "invalid /policies/check body",
@@ -4976,7 +5246,15 @@ function registerRoutes(scope, ctx) {
4976
5246
  async function buildApi(opts) {
4977
5247
  const app = (0, import_fastify.default)({ logger: false });
4978
5248
  await app.register(import_cors.default, { origin: true });
4979
- mountBearerAuth(app, { token: opts.authToken, trustProxy: opts.trustProxy });
5249
+ mountBearerAuth(app, {
5250
+ token: opts.authToken,
5251
+ trustProxy: opts.trustProxy,
5252
+ publicRead: opts.publicRead
5253
+ });
5254
+ app.get("/api/config", async () => ({
5255
+ publicRead: opts.publicRead === true,
5256
+ authProxy: opts.trustProxy === true
5257
+ }));
4980
5258
  const startedAt = opts.startedAt ?? Date.now();
4981
5259
  const registry = buildLegacyRegistry(opts);
4982
5260
  const legacyErrorsExplicit = !opts.projects && opts.errorsPath !== void 0;
@@ -5000,10 +5278,36 @@ async function buildApi(opts) {
5000
5278
  const routeCtx = {
5001
5279
  registry,
5002
5280
  startedAt,
5281
+ scope: "root",
5003
5282
  errorsPathFor,
5004
5283
  staleEventsPathFor,
5005
- policyFilePathFor
5284
+ policyFilePathFor,
5285
+ bootstrap: opts.bootstrap
5006
5286
  };
5287
+ app.get("/health", async () => {
5288
+ const uptimeMs = Date.now() - startedAt;
5289
+ const bootstrapList = opts.bootstrap?.list() ?? [];
5290
+ const byName = new Map(bootstrapList.map((p) => [p.name, p]));
5291
+ const names = /* @__PURE__ */ new Set([
5292
+ ...registry.list(),
5293
+ ...bootstrapList.map((p) => p.name)
5294
+ ]);
5295
+ const projects = [...names].sort().map((name) => {
5296
+ const proj = registry.get(name);
5297
+ const tracked = byName.get(name);
5298
+ return {
5299
+ name,
5300
+ nodeCount: proj?.graph.order ?? 0,
5301
+ edgeCount: proj?.graph.size ?? 0,
5302
+ ...tracked ? { status: tracked.status, elapsedMs: tracked.elapsedMs } : {}
5303
+ };
5304
+ });
5305
+ return {
5306
+ ok: true,
5307
+ uptimeMs,
5308
+ projects
5309
+ };
5310
+ });
5007
5311
  app.get("/projects", async (_req, reply) => {
5008
5312
  try {
5009
5313
  return await listProjects();
@@ -5031,120 +5335,13 @@ async function buildApi(opts) {
5031
5335
  registerRoutes(app, routeCtx);
5032
5336
  await app.register(
5033
5337
  async (scope) => {
5034
- registerRoutes(scope, routeCtx);
5338
+ registerRoutes(scope, { ...routeCtx, scope: "project" });
5035
5339
  },
5036
5340
  { prefix: "/projects/:project" }
5037
5341
  );
5038
5342
  return app;
5039
5343
  }
5040
5344
 
5041
- // src/persist.ts
5042
- init_cjs_shims();
5043
- var import_node_fs20 = require("fs");
5044
- var import_node_path33 = __toESM(require("path"), 1);
5045
- var import_types22 = require("@neat.is/types");
5046
- var SCHEMA_VERSION = 3;
5047
- function migrateV1ToV2(payload) {
5048
- const nodes = payload.graph.nodes;
5049
- if (Array.isArray(nodes)) {
5050
- for (const node of nodes) {
5051
- if (node.attributes && "pgDriverVersion" in node.attributes) {
5052
- delete node.attributes.pgDriverVersion;
5053
- }
5054
- }
5055
- }
5056
- return { ...payload, schemaVersion: 2 };
5057
- }
5058
- function migrateV2ToV3(payload) {
5059
- const edges = payload.graph.edges;
5060
- if (Array.isArray(edges)) {
5061
- for (const edge of edges) {
5062
- const attrs = edge.attributes;
5063
- if (!attrs || attrs.provenance !== "FRONTIER") continue;
5064
- attrs.provenance = import_types22.Provenance.OBSERVED;
5065
- const type = typeof attrs.type === "string" ? attrs.type : void 0;
5066
- const source = typeof attrs.source === "string" ? attrs.source : void 0;
5067
- const target = typeof attrs.target === "string" ? attrs.target : void 0;
5068
- if (type && source && target) {
5069
- const newId = (0, import_types22.observedEdgeId)(source, target, type);
5070
- attrs.id = newId;
5071
- if (edge.key) edge.key = newId;
5072
- }
5073
- }
5074
- }
5075
- return { ...payload, schemaVersion: 3 };
5076
- }
5077
- async function ensureDir(filePath) {
5078
- await import_node_fs20.promises.mkdir(import_node_path33.default.dirname(filePath), { recursive: true });
5079
- }
5080
- async function saveGraphToDisk(graph, outPath) {
5081
- await ensureDir(outPath);
5082
- const payload = {
5083
- schemaVersion: SCHEMA_VERSION,
5084
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
5085
- graph: graph.export()
5086
- };
5087
- const tmp = `${outPath}.tmp`;
5088
- await import_node_fs20.promises.writeFile(tmp, JSON.stringify(payload), "utf8");
5089
- await import_node_fs20.promises.rename(tmp, outPath);
5090
- }
5091
- async function loadGraphFromDisk(graph, outPath) {
5092
- let raw;
5093
- try {
5094
- raw = await import_node_fs20.promises.readFile(outPath, "utf8");
5095
- } catch (err) {
5096
- if (err.code === "ENOENT") return;
5097
- throw err;
5098
- }
5099
- let payload = JSON.parse(raw);
5100
- if (payload.schemaVersion === 1) {
5101
- payload = migrateV1ToV2(payload);
5102
- }
5103
- if (payload.schemaVersion === 2) {
5104
- payload = migrateV2ToV3(payload);
5105
- }
5106
- if (payload.schemaVersion !== SCHEMA_VERSION) {
5107
- throw new Error(
5108
- `persist: unsupported snapshot schemaVersion ${payload.schemaVersion} (expected ${SCHEMA_VERSION})`
5109
- );
5110
- }
5111
- graph.clear();
5112
- graph.import(payload.graph);
5113
- }
5114
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5115
- let stopped = false;
5116
- const tick = async () => {
5117
- if (stopped) return;
5118
- try {
5119
- await saveGraphToDisk(graph, outPath);
5120
- } catch (err) {
5121
- console.error("persist: periodic save failed", err);
5122
- }
5123
- };
5124
- const interval = setInterval(() => {
5125
- void tick();
5126
- }, intervalMs);
5127
- const onSignal = (signal) => {
5128
- void (async () => {
5129
- try {
5130
- await saveGraphToDisk(graph, outPath);
5131
- } catch (err) {
5132
- console.error(`persist: ${signal} save failed`, err);
5133
- } finally {
5134
- process.exit(0);
5135
- }
5136
- })();
5137
- };
5138
- process.on("SIGTERM", onSignal);
5139
- process.on("SIGINT", onSignal);
5140
- return () => {
5141
- stopped = true;
5142
- clearInterval(interval);
5143
- process.off("SIGTERM", onSignal);
5144
- process.off("SIGINT", onSignal);
5145
- };
5146
- }
5147
-
5148
5345
  // src/server.ts
5149
5346
  init_otel();
5150
5347
  init_otel_grpc();
@@ -5489,7 +5686,8 @@ async function main() {
5489
5686
  const app = await buildApi({
5490
5687
  projects: registry,
5491
5688
  authToken: auth.authToken,
5492
- trustProxy: auth.trustProxy
5689
+ trustProxy: auth.trustProxy,
5690
+ publicRead: auth.publicRead
5493
5691
  });
5494
5692
  await app.listen({ port, host });
5495
5693
  console.log(`neat-core listening on http://${host}:${port}`);