@neat.is/core 0.4.27 → 0.4.28-dev.20260708

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.
@@ -0,0 +1,2566 @@
1
+ import {
2
+ DEFAULT_PROJECT,
3
+ Projects,
4
+ attachGraphToEventBus,
5
+ buildApi,
6
+ ensureObservedFileNode,
7
+ ensureServiceNode,
8
+ extractFromDirectory,
9
+ getGraph,
10
+ handleSpan,
11
+ listProjects,
12
+ loadGraphFromDisk,
13
+ makeErrorSpanWriter,
14
+ normalizePathTemplate,
15
+ pathsForProject,
16
+ pruneRegistry,
17
+ reconcileObservedRelPath,
18
+ registryPath,
19
+ resetGraph,
20
+ saveGraphToDisk,
21
+ setStatus,
22
+ startPersistLoop,
23
+ startStalenessLoop,
24
+ touchLastSeen,
25
+ upsertObservedEdge,
26
+ writeAtomically
27
+ } from "./chunk-O4MRDWD7.js";
28
+ import {
29
+ assertBindAuthority,
30
+ buildOtelReceiver,
31
+ listenSteppingOtlp,
32
+ readAuthEnv
33
+ } from "./chunk-CFDPIMRP.js";
34
+
35
+ // src/daemon.ts
36
+ import {
37
+ promises as fs3,
38
+ watch,
39
+ renameSync,
40
+ unlinkSync,
41
+ writeFileSync
42
+ } from "fs";
43
+ import path3 from "path";
44
+ import { createRequire } from "module";
45
+
46
+ // src/connectors/index.ts
47
+ var NO_ENV = "unknown";
48
+ async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
49
+ const signals = await connector.poll(ctx);
50
+ let edgesCreated = 0;
51
+ let edgesUpdated = 0;
52
+ let unresolved = 0;
53
+ for (const signal of signals) {
54
+ const resolved = resolveTarget(signal, ctx);
55
+ if (!resolved) {
56
+ unresolved++;
57
+ continue;
58
+ }
59
+ const serviceNodeId = ensureServiceNode(graph, resolved.serviceName, NO_ENV);
60
+ const callSite = signal.callSite ? { relPath: signal.callSite.file, line: signal.callSite.line } : void 0;
61
+ const sourceId = callSite ? ensureObservedFileNode(graph, resolved.serviceName, serviceNodeId, callSite) : serviceNodeId;
62
+ const evidence = callSite ? {
63
+ file: reconcileObservedRelPath(graph, resolved.serviceName, callSite.relPath),
64
+ line: callSite.line
65
+ } : void 0;
66
+ const calls = Math.trunc(signal.callCount);
67
+ if (calls < 1) continue;
68
+ const errors = Math.min(Math.max(Math.trunc(signal.errorCount), 0), calls);
69
+ let created = false;
70
+ let ok = true;
71
+ for (let i = 0; i < calls; i++) {
72
+ const result = upsertObservedEdge(
73
+ graph,
74
+ resolved.edgeType,
75
+ sourceId,
76
+ resolved.targetNodeId,
77
+ signal.lastObservedIso,
78
+ i < errors,
79
+ evidence
80
+ );
81
+ if (!result) {
82
+ ok = false;
83
+ break;
84
+ }
85
+ if (i === 0) created = result.created;
86
+ }
87
+ if (!ok) {
88
+ unresolved++;
89
+ continue;
90
+ }
91
+ if (created) edgesCreated++;
92
+ else edgesUpdated++;
93
+ }
94
+ return { signalCount: signals.length, edgesCreated, edgesUpdated, unresolved };
95
+ }
96
+ var DEFAULT_POLL_INTERVAL_MS = 6e4;
97
+ function startConnectorPollLoop(connector, ctx, graph, resolveTarget, options = {}) {
98
+ let stopped = false;
99
+ let since = ctx.since;
100
+ const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
101
+ const onError = options.onError ?? ((err) => console.error(`[neatd] connector poll failed (${connector.provider})`, err));
102
+ const tick = () => {
103
+ if (stopped) return;
104
+ void (async () => {
105
+ const tickStartedAt = (/* @__PURE__ */ new Date()).toISOString();
106
+ try {
107
+ await runConnectorPoll(connector, { ...ctx, since }, graph, resolveTarget);
108
+ since = tickStartedAt;
109
+ } catch (err) {
110
+ onError(err);
111
+ }
112
+ })();
113
+ };
114
+ const interval = setInterval(tick, intervalMs);
115
+ if (typeof interval.unref === "function") interval.unref();
116
+ return () => {
117
+ stopped = true;
118
+ clearInterval(interval);
119
+ };
120
+ }
121
+
122
+ // src/connectors/junction.ts
123
+ var buckets = /* @__PURE__ */ new Map();
124
+ function bucketMapKey(provider, accountKey) {
125
+ return `${provider}\0${accountKey}`;
126
+ }
127
+ function getBucket(provider, accountKey, config) {
128
+ const key = bucketMapKey(provider, accountKey);
129
+ const existing = buckets.get(key);
130
+ if (existing && existing.capacity === config.capacity && existing.refillMs === config.refillMs) {
131
+ return existing;
132
+ }
133
+ const fresh = { ...config, tokens: config.capacity, updatedAt: Date.now() };
134
+ buckets.set(key, fresh);
135
+ return fresh;
136
+ }
137
+ function refillBucket(bucket, now) {
138
+ if (now <= bucket.updatedAt) return;
139
+ const elapsed = now - bucket.updatedAt;
140
+ const grant = elapsed / bucket.refillMs;
141
+ if (grant <= 0) return;
142
+ bucket.tokens = Math.min(bucket.capacity, bucket.tokens + grant);
143
+ bucket.updatedAt = now;
144
+ }
145
+ var RateLimitExceededError = class extends Error {
146
+ constructor(provider, accountKey) {
147
+ super(
148
+ `junction: rate limit exceeded for ${provider}:${accountKey} \u2014 waiting for the next token would exceed this call's wall-clock budget`
149
+ );
150
+ this.name = "RateLimitExceededError";
151
+ }
152
+ };
153
+ function delay(ms) {
154
+ if (ms <= 0) return Promise.resolve();
155
+ return new Promise((resolve) => {
156
+ const timer = setTimeout(resolve, ms);
157
+ if (typeof timer.unref === "function") timer.unref();
158
+ });
159
+ }
160
+ async function acquireToken(provider, accountKey, config, remainingBudgetMs) {
161
+ const bucket = getBucket(provider, accountKey, config);
162
+ refillBucket(bucket, Date.now());
163
+ if (bucket.tokens >= 1) {
164
+ bucket.tokens -= 1;
165
+ return 0;
166
+ }
167
+ const waitMs = Math.ceil((1 - bucket.tokens) * bucket.refillMs);
168
+ if (waitMs > remainingBudgetMs) {
169
+ throw new RateLimitExceededError(provider, accountKey);
170
+ }
171
+ await delay(waitMs);
172
+ refillBucket(bucket, Date.now());
173
+ bucket.tokens = Math.max(0, bucket.tokens - 1);
174
+ return waitMs;
175
+ }
176
+ var JUNCTION_DEFAULT_RATE_LIMITS = {
177
+ // ~300 requests / 5 minutes (ADR-131). Burst capacity holds a third of
178
+ // that ceiling; steady-state refill (1 token / 3s = 20/min = 100/5min)
179
+ // stays well clear of the documented limit even under sustained polling.
180
+ cloudflare: { capacity: 100, refillMs: 3e3 },
181
+ // Placeholder pending a live project confirming the real cap
182
+ // (docs/connectors/railway.md: "does not appear to publish one as of this
183
+ // writing").
184
+ railway: { capacity: 30, refillMs: 1e4 },
185
+ // Placeholder pending a live rate-limit check (docs/connectors/
186
+ // firebase.md: "needs-endpoint-testing against entries.list's live rate
187
+ // limits").
188
+ firebase: { capacity: 30, refillMs: 1e4 },
189
+ // Placeholder pending a live rate-limit check (docs/connectors/supabase.md:
190
+ // "the documented rate limit for this specific endpoint is unconfirmed").
191
+ supabase: { capacity: 30, refillMs: 1e4 },
192
+ // Not a documented API limit at all — a self-imposed ceiling on the raw
193
+ // pg_stat_statements connection (see module header above).
194
+ "supabase-postgres": { capacity: 20, refillMs: 3e3 }
195
+ };
196
+ var JUNCTION_GENERIC_RATE_LIMIT = { capacity: 20, refillMs: 5e3 };
197
+ function defaultRateLimitFor(provider) {
198
+ return JUNCTION_DEFAULT_RATE_LIMITS[provider] ?? JUNCTION_GENERIC_RATE_LIMIT;
199
+ }
200
+ var JUNCTION_DEFAULT_TIMEOUT_MS = 1e4;
201
+ var JUNCTION_DEFAULT_MAX_ATTEMPTS = 3;
202
+ var JUNCTION_DEFAULT_INITIAL_BACKOFF_MS = 200;
203
+ var JUNCTION_DEFAULT_BACKOFF_MULTIPLIER = 4;
204
+ var JUNCTION_DEFAULT_MAX_ELAPSED_MS = 3e4;
205
+ var JUNCTION_DEFAULT_DB_TIMEOUT_MS = 1e4;
206
+ async function backoff(attempt, initialBackoffMs, backoffMultiplier, remainingBudgetMs) {
207
+ const raw = initialBackoffMs * backoffMultiplier ** (attempt - 1);
208
+ const capped = Math.max(0, Math.min(raw, remainingBudgetMs));
209
+ await delay(capped);
210
+ }
211
+ function safeUrlLabel(url) {
212
+ try {
213
+ const u = typeof url === "string" ? new URL(url) : url;
214
+ return `${u.origin}${u.pathname}`;
215
+ } catch {
216
+ return String(url);
217
+ }
218
+ }
219
+ function logOutcome(provider, accountKey, outcome, method, label, attempt, startedAt) {
220
+ const elapsedMs = Date.now() - startedAt;
221
+ const line = `[neat connector] ${provider}:${accountKey} ${method} ${label} \u2014 ${outcome} (attempt ${attempt}, ${elapsedMs}ms)`;
222
+ if (outcome === "success" || outcome === "retried-then-succeeded") {
223
+ console.log(line);
224
+ } else if (outcome === "rate-limited") {
225
+ console.warn(line);
226
+ } else {
227
+ console.error(line);
228
+ }
229
+ }
230
+ function bearerAuthHeader(token) {
231
+ return { Authorization: `Bearer ${token}` };
232
+ }
233
+ async function junctionFetch(url, init = {}, policy) {
234
+ const {
235
+ provider,
236
+ accountKey,
237
+ timeoutMs = JUNCTION_DEFAULT_TIMEOUT_MS,
238
+ maxAttempts = JUNCTION_DEFAULT_MAX_ATTEMPTS,
239
+ maxElapsedMs = JUNCTION_DEFAULT_MAX_ELAPSED_MS,
240
+ initialBackoffMs = JUNCTION_DEFAULT_INITIAL_BACKOFF_MS,
241
+ backoffMultiplier = JUNCTION_DEFAULT_BACKOFF_MULTIPLIER,
242
+ rateLimit = defaultRateLimitFor(provider),
243
+ fetchImpl = fetch
244
+ } = policy;
245
+ const method = (init.method ?? "GET").toUpperCase();
246
+ const label = safeUrlLabel(url);
247
+ const startedAt = Date.now();
248
+ let attempt = 0;
249
+ let sawRetry = false;
250
+ for (; ; ) {
251
+ attempt++;
252
+ const remainingBudget = maxElapsedMs - (Date.now() - startedAt);
253
+ if (remainingBudget <= 0) {
254
+ logOutcome(provider, accountKey, "retried-then-failed", method, label, attempt - 1, startedAt);
255
+ throw new Error(
256
+ `junction: ${provider}:${accountKey} ${method} ${label} exceeded its wall-clock budget (${maxElapsedMs}ms) after ${attempt - 1} attempt(s)`
257
+ );
258
+ }
259
+ try {
260
+ await acquireToken(provider, accountKey, rateLimit, remainingBudget);
261
+ } catch (err) {
262
+ if (err instanceof RateLimitExceededError) {
263
+ logOutcome(provider, accountKey, "rate-limited", method, label, attempt, startedAt);
264
+ }
265
+ throw err;
266
+ }
267
+ const controller = new AbortController();
268
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
269
+ if (typeof timer.unref === "function") timer.unref();
270
+ try {
271
+ const res = await fetchImpl(url, { ...init, signal: controller.signal });
272
+ clearTimeout(timer);
273
+ if (res.ok || res.status < 500) {
274
+ logOutcome(provider, accountKey, sawRetry ? "retried-then-succeeded" : "success", method, label, attempt, startedAt);
275
+ return res;
276
+ }
277
+ if (attempt >= maxAttempts) {
278
+ logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", method, label, attempt, startedAt);
279
+ return res;
280
+ }
281
+ sawRetry = true;
282
+ await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
283
+ } catch (err) {
284
+ clearTimeout(timer);
285
+ if (attempt >= maxAttempts) {
286
+ logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", method, label, attempt, startedAt);
287
+ throw err;
288
+ }
289
+ sawRetry = true;
290
+ await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
291
+ }
292
+ }
293
+ }
294
+ var DbJunctionTimeoutError = class extends Error {
295
+ constructor(ms) {
296
+ super(`junction: db query exceeded its ${ms}ms timeout`);
297
+ this.name = "DbJunctionTimeoutError";
298
+ }
299
+ };
300
+ function withTimeout(run, timeoutMs) {
301
+ return new Promise((resolve, reject) => {
302
+ const timer = setTimeout(() => reject(new DbJunctionTimeoutError(timeoutMs)), timeoutMs);
303
+ if (typeof timer.unref === "function") timer.unref();
304
+ run().then(
305
+ (value) => {
306
+ clearTimeout(timer);
307
+ resolve(value);
308
+ },
309
+ (err) => {
310
+ clearTimeout(timer);
311
+ reject(err);
312
+ }
313
+ );
314
+ });
315
+ }
316
+ var RETRYABLE_NODE_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "EAI_AGAIN", "EPIPE"]);
317
+ function isRetryableDbError(err) {
318
+ if (err instanceof DbJunctionTimeoutError) return true;
319
+ const code = err?.code;
320
+ if (typeof code !== "string") return false;
321
+ if (code.startsWith("08") || code === "57P03") return true;
322
+ return RETRYABLE_NODE_ERROR_CODES.has(code);
323
+ }
324
+ async function dbJunction(run, policy) {
325
+ const {
326
+ provider,
327
+ accountKey,
328
+ timeoutMs = JUNCTION_DEFAULT_DB_TIMEOUT_MS,
329
+ maxAttempts = JUNCTION_DEFAULT_MAX_ATTEMPTS,
330
+ maxElapsedMs = JUNCTION_DEFAULT_MAX_ELAPSED_MS,
331
+ initialBackoffMs = JUNCTION_DEFAULT_INITIAL_BACKOFF_MS,
332
+ backoffMultiplier = JUNCTION_DEFAULT_BACKOFF_MULTIPLIER,
333
+ rateLimit = defaultRateLimitFor(provider)
334
+ } = policy;
335
+ const startedAt = Date.now();
336
+ let attempt = 0;
337
+ let sawRetry = false;
338
+ for (; ; ) {
339
+ attempt++;
340
+ const remainingBudget = maxElapsedMs - (Date.now() - startedAt);
341
+ if (remainingBudget <= 0) {
342
+ logOutcome(provider, accountKey, "retried-then-failed", "QUERY", "db", attempt - 1, startedAt);
343
+ throw new Error(
344
+ `junction: ${provider}:${accountKey} db query exceeded its wall-clock budget (${maxElapsedMs}ms) after ${attempt - 1} attempt(s)`
345
+ );
346
+ }
347
+ try {
348
+ await acquireToken(provider, accountKey, rateLimit, remainingBudget);
349
+ } catch (err) {
350
+ if (err instanceof RateLimitExceededError) {
351
+ logOutcome(provider, accountKey, "rate-limited", "QUERY", "db", attempt, startedAt);
352
+ }
353
+ throw err;
354
+ }
355
+ try {
356
+ const result = await withTimeout(run, timeoutMs);
357
+ logOutcome(provider, accountKey, sawRetry ? "retried-then-succeeded" : "success", "QUERY", "db", attempt, startedAt);
358
+ return result;
359
+ } catch (err) {
360
+ if (!isRetryableDbError(err) || attempt >= maxAttempts) {
361
+ logOutcome(provider, accountKey, sawRetry ? "retried-then-failed" : "failed", "QUERY", "db", attempt, startedAt);
362
+ throw err;
363
+ }
364
+ sawRetry = true;
365
+ await backoff(attempt, initialBackoffMs, backoffMultiplier, maxElapsedMs - (Date.now() - startedAt));
366
+ }
367
+ }
368
+ }
369
+
370
+ // src/connectors/supabase/client.ts
371
+ var DEFAULT_SUPABASE_MANAGEMENT_API_URL = "https://api.supabase.com";
372
+ var DEFAULT_LOG_LIMIT = 1e3;
373
+ var SUPABASE_LOG_QUERY_MAX_WINDOW_MS = 24 * 60 * 60 * 1e3;
374
+ function boundedSupabaseLogWindow(since, now, maxLookbackMs) {
375
+ const window = Math.min(maxLookbackMs, SUPABASE_LOG_QUERY_MAX_WINDOW_MS);
376
+ const floor = new Date(now.getTime() - window);
377
+ const endIso = now.toISOString();
378
+ if (!since) return { startIso: floor.toISOString(), endIso, truncated: false };
379
+ const sinceMs = new Date(since).getTime();
380
+ if (Number.isNaN(sinceMs)) return { startIso: floor.toISOString(), endIso, truncated: false };
381
+ if (sinceMs < floor.getTime()) return { startIso: floor.toISOString(), endIso, truncated: true };
382
+ return { startIso: new Date(sinceMs).toISOString(), endIso, truncated: false };
383
+ }
384
+ function buildEdgeLogsQuery(limit) {
385
+ const safeLimit = Math.max(1, Math.trunc(limit) || DEFAULT_LOG_LIMIT);
386
+ return [
387
+ "select",
388
+ " format_timestamp('%Y-%m-%dT%H:%M:%E6SZ', timestamp) as timestamp,",
389
+ " request.method as method,",
390
+ " request.path as path,",
391
+ " response.status_code as status_code",
392
+ "from edge_logs",
393
+ "cross join unnest(metadata) as metadata",
394
+ "cross join unnest(metadata.request) as request",
395
+ "cross join unnest(metadata.response) as response",
396
+ "where regexp_contains(request.path, '^/rest/v1/')",
397
+ "order by timestamp asc",
398
+ `limit ${safeLimit}`
399
+ ].join("\n");
400
+ }
401
+ async function fetchSupabaseEdgeLogs(config, token, startIso, endIso, fetchImpl = fetch) {
402
+ const baseUrl = config.managementApiUrl ?? DEFAULT_SUPABASE_MANAGEMENT_API_URL;
403
+ const url = new URL(`${baseUrl}/v1/projects/${config.apiProjectRef}/analytics/endpoints/logs.all`);
404
+ url.searchParams.set("sql", buildEdgeLogsQuery(config.logLimit ?? DEFAULT_LOG_LIMIT));
405
+ url.searchParams.set("iso_timestamp_start", startIso);
406
+ url.searchParams.set("iso_timestamp_end", endIso);
407
+ const res = await junctionFetch(
408
+ url,
409
+ { method: "GET", headers: bearerAuthHeader(token) },
410
+ // accountKey: the Supabase project ref (ADR-131's own worked example) —
411
+ // the Management API's rate limit is enforced per project.
412
+ { provider: "supabase", accountKey: config.apiProjectRef, fetchImpl }
413
+ );
414
+ if (!res.ok) {
415
+ throw new Error(`supabase connector: logs.all request failed (${res.status} ${res.statusText})`);
416
+ }
417
+ const body = await res.json();
418
+ if (body.error) {
419
+ const message = typeof body.error === "string" ? body.error : body.error.message;
420
+ throw new Error(`supabase connector: logs.all returned an error (${message})`);
421
+ }
422
+ return body.result ?? [];
423
+ }
424
+
425
+ // src/connectors/supabase/types.ts
426
+ function readSupabaseCredentials(raw) {
427
+ const managementToken = raw["managementToken"];
428
+ if (typeof managementToken !== "string" || managementToken.length === 0) {
429
+ throw new Error("supabase connector: credentials.managementToken must be a non-empty string");
430
+ }
431
+ const postgresConnectionString = raw["postgresConnectionString"];
432
+ if (postgresConnectionString !== void 0 && (typeof postgresConnectionString !== "string" || postgresConnectionString.length === 0)) {
433
+ throw new Error(
434
+ "supabase connector: credentials.postgresConnectionString must be a non-empty string when present"
435
+ );
436
+ }
437
+ return {
438
+ managementToken,
439
+ ...postgresConnectionString ? { postgresConnectionString } : {}
440
+ };
441
+ }
442
+ var SUPABASE_TABLE_TARGET_KIND = "supabase-table";
443
+ var SUPABASE_RPC_TARGET_KIND = "supabase-rpc";
444
+
445
+ // src/connectors/supabase/map.ts
446
+ var REST_RPC_PATH_RE = /^\/rest\/v1\/rpc\/([^/?]+)/;
447
+ var REST_TABLE_PATH_RE = /^\/rest\/v1\/([^/?]+)/;
448
+ function targetFromRestPath(path4) {
449
+ const rpcMatch = REST_RPC_PATH_RE.exec(path4);
450
+ if (rpcMatch) return { targetKind: SUPABASE_RPC_TARGET_KIND, name: rpcMatch[1] };
451
+ const tableMatch = REST_TABLE_PATH_RE.exec(path4);
452
+ if (tableMatch) return { targetKind: SUPABASE_TABLE_TARGET_KIND, name: tableMatch[1] };
453
+ return null;
454
+ }
455
+ var ERROR_STATUS_THRESHOLD = 500;
456
+ function mapEdgeLogRowsToSignals(rows) {
457
+ const buckets2 = /* @__PURE__ */ new Map();
458
+ for (const row of rows) {
459
+ const target = targetFromRestPath(row.path);
460
+ if (!target) continue;
461
+ const key = `${target.targetKind}:${target.name}`;
462
+ const isError = row.status_code >= ERROR_STATUS_THRESHOLD;
463
+ const existing = buckets2.get(key);
464
+ if (existing) {
465
+ existing.callCount += 1;
466
+ if (isError) existing.errorCount += 1;
467
+ if (row.timestamp > existing.lastObservedIso) existing.lastObservedIso = row.timestamp;
468
+ } else {
469
+ buckets2.set(key, {
470
+ targetKind: target.targetKind,
471
+ targetName: target.name,
472
+ callCount: 1,
473
+ errorCount: isError ? 1 : 0,
474
+ lastObservedIso: row.timestamp
475
+ });
476
+ }
477
+ }
478
+ return [...buckets2.values()].map((b) => ({
479
+ targetKind: b.targetKind,
480
+ targetName: b.targetName,
481
+ callCount: b.callCount,
482
+ errorCount: b.errorCount,
483
+ lastObservedIso: b.lastObservedIso
484
+ }));
485
+ }
486
+ var FROM_TABLE_RE = /\bfrom\s+"?(?:[a-z_][a-z0-9_]*"?\.)?"?([a-z_][a-z0-9_]*)"?/i;
487
+ var SYSTEM_SCHEMA_PREFIXES = ["pg_", "information_schema"];
488
+ function tableNameFromQueryText(query) {
489
+ const match = FROM_TABLE_RE.exec(query);
490
+ if (!match) return null;
491
+ const name = match[1];
492
+ const lower = name.toLowerCase();
493
+ if (SYSTEM_SCHEMA_PREFIXES.some((prefix) => lower.startsWith(prefix))) return null;
494
+ return name;
495
+ }
496
+ function diffPgStatStatementsToSignals(rows, previous, nowIso) {
497
+ const signals = [];
498
+ const seen = /* @__PURE__ */ new Set();
499
+ for (const row of rows) {
500
+ seen.add(row.queryid);
501
+ const calls = Number(row.calls);
502
+ const prior = previous.get(row.queryid);
503
+ previous.set(row.queryid, { calls });
504
+ if (!prior || calls < prior.calls) continue;
505
+ const delta = calls - prior.calls;
506
+ if (delta <= 0) continue;
507
+ const table = tableNameFromQueryText(row.query);
508
+ if (!table) continue;
509
+ signals.push({
510
+ targetKind: SUPABASE_TABLE_TARGET_KIND,
511
+ targetName: table,
512
+ callCount: delta,
513
+ errorCount: 0,
514
+ lastObservedIso: nowIso
515
+ });
516
+ }
517
+ for (const queryid of [...previous.keys()]) {
518
+ if (!seen.has(queryid)) previous.delete(queryid);
519
+ }
520
+ return signals;
521
+ }
522
+
523
+ // src/connectors/supabase/postgres-client.ts
524
+ import pg from "pg";
525
+ var { Client } = pg;
526
+ var DEFAULT_STATEMENT_LIMIT = 500;
527
+ var STATEMENTS_QUERY = `
528
+ select queryid, query, calls, total_exec_time, rows
529
+ from pg_stat_statements
530
+ where query ~* '^\\s*select\\b'
531
+ order by calls desc
532
+ limit $1
533
+ `;
534
+ async function fetchPgStatStatements(connectionString, limit = DEFAULT_STATEMENT_LIMIT, accountKey = "unknown", clientFactory = (cs) => new Client({ connectionString: cs })) {
535
+ return dbJunction(
536
+ async () => {
537
+ const client = clientFactory(connectionString);
538
+ await client.connect();
539
+ try {
540
+ await client.query("SET default_transaction_read_only = on");
541
+ const result = await client.query(STATEMENTS_QUERY, [limit]);
542
+ return result.rows;
543
+ } finally {
544
+ await client.end();
545
+ }
546
+ },
547
+ { provider: "supabase-postgres", accountKey }
548
+ );
549
+ }
550
+
551
+ // src/connectors/supabase/resolve.ts
552
+ import { EdgeType, infraId } from "@neat.is/types";
553
+ function createSupabaseResolveTarget(graph, config) {
554
+ return (signal, _ctx) => {
555
+ if (signal.targetKind !== SUPABASE_TABLE_TARGET_KIND && signal.targetKind !== SUPABASE_RPC_TARGET_KIND) {
556
+ return null;
557
+ }
558
+ const subResourceId = infraId(signal.targetKind, `${config.nodeRef}/${signal.targetName}`);
559
+ if (graph.hasNode(subResourceId)) {
560
+ return { targetNodeId: subResourceId, serviceName: config.serviceName, edgeType: EdgeType.CALLS };
561
+ }
562
+ const projectLevelId = infraId("supabase", config.nodeRef);
563
+ if (graph.hasNode(projectLevelId)) {
564
+ return { targetNodeId: projectLevelId, serviceName: config.serviceName, edgeType: EdgeType.CALLS };
565
+ }
566
+ return null;
567
+ };
568
+ }
569
+
570
+ // src/connectors/supabase/index.ts
571
+ var DEFAULT_MAX_LOOKBACK_MS = 24 * 60 * 60 * 1e3;
572
+ var SupabaseConnector = class {
573
+ // `deps.fetchPgStatStatements` defaults to the real Postgres-backed
574
+ // implementation; tests override it to exercise the "both surfaces
575
+ // combine" and "surface 2 only runs when a connection string is present"
576
+ // behavior without a live database — the same dependency-injection seam
577
+ // `fetchImpl` gives cloudflare/client.ts's tests for `fetch`.
578
+ constructor(config, deps = {}) {
579
+ this.config = config;
580
+ this.deps = deps;
581
+ }
582
+ config;
583
+ deps;
584
+ provider = "supabase";
585
+ // pg_stat_statements.calls is cumulative, not per-window (map.ts's
586
+ // diffPgStatStatementsToSignals doc comment) — this Map carries the
587
+ // previous poll's counts across ticks, the same way
588
+ // `startConnectorPollLoop` (connectors/index.ts) carries `since` across
589
+ // ticks for every connector. Lives on the instance, not `ConnectorContext`,
590
+ // because `ConnectorContext` is rebuilt fresh per tick (connectors/index.ts's
591
+ // `{ ...ctx, since }`) while this connector object is the one thing every
592
+ // tick shares.
593
+ statementBaselines = /* @__PURE__ */ new Map();
594
+ async poll(ctx) {
595
+ const creds = readSupabaseCredentials(ctx.credentials);
596
+ const now = /* @__PURE__ */ new Date();
597
+ const maxLookbackMs = this.config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS;
598
+ const { startIso, endIso } = boundedSupabaseLogWindow(ctx.since, now, maxLookbackMs);
599
+ const logRows = await fetchSupabaseEdgeLogs(this.config, creds.managementToken, startIso, endIso);
600
+ const signals = mapEdgeLogRowsToSignals(logRows);
601
+ if (creds.postgresConnectionString) {
602
+ const fetchStatements = this.deps.fetchPgStatStatements ?? fetchPgStatStatements;
603
+ const statementRows = await fetchStatements(
604
+ creds.postgresConnectionString,
605
+ this.config.statementLimit ?? DEFAULT_STATEMENT_LIMIT,
606
+ this.config.apiProjectRef
607
+ );
608
+ signals.push(...diffPgStatStatementsToSignals(statementRows, this.statementBaselines, now.toISOString()));
609
+ }
610
+ return signals;
611
+ }
612
+ };
613
+ function createSupabaseConnector(graph, config, deps = {}) {
614
+ return {
615
+ connector: new SupabaseConnector(config, deps),
616
+ resolveTarget: createSupabaseResolveTarget(graph, config)
617
+ };
618
+ }
619
+
620
+ // src/connectors/railway/index.ts
621
+ import { EdgeType as EdgeType2, NodeType, serviceId } from "@neat.is/types";
622
+
623
+ // src/connectors/railway/client.ts
624
+ var DEFAULT_RAILWAY_API_URL = "https://backboard.railway.com/graphql/v2";
625
+ var DEFAULT_MAX_LOOKBACK_MS2 = 24 * 60 * 60 * 1e3;
626
+ var DEFAULT_LOG_LIMIT2 = 1e3;
627
+ function readRailwayToken(credentials) {
628
+ const token = credentials.token;
629
+ if (typeof token !== "string" || token.length === 0) {
630
+ throw new Error(
631
+ "Railway connector requires ctx.credentials.token (a Project-Access-Token or account Bearer token)"
632
+ );
633
+ }
634
+ return token;
635
+ }
636
+ async function railwayGraphQL(apiUrl, token, query, variables, accountKey) {
637
+ const res = await junctionFetch(
638
+ apiUrl,
639
+ {
640
+ method: "POST",
641
+ headers: {
642
+ "Content-Type": "application/json",
643
+ // docs.railway.com/integrations/api/graphql-overview confirms
644
+ // `Authorization: Bearer <token>` for an account/workspace token;
645
+ // Railway also documents a dedicated `Project-Access-Token: <token>`
646
+ // header for the project-scoped token ADR-127 targets specifically.
647
+ // This connector sends the Bearer form — needs-endpoint-testing
648
+ // whether a live Project-Access-Token requires the dedicated header
649
+ // instead once this poller runs against a real project.
650
+ ...bearerAuthHeader(token)
651
+ },
652
+ body: JSON.stringify({ query, variables })
653
+ },
654
+ { provider: "railway", accountKey }
655
+ );
656
+ if (!res.ok) {
657
+ throw new Error(`Railway GraphQL request failed: ${res.status} ${res.statusText}`);
658
+ }
659
+ const body = await res.json();
660
+ if (body.errors && body.errors.length > 0) {
661
+ throw new Error(`Railway GraphQL errors: ${body.errors.map((e) => e.message).join("; ")}`);
662
+ }
663
+ if (!body.data) throw new Error("Railway GraphQL response carried no data");
664
+ return body.data;
665
+ }
666
+ var HTTP_LOGS_QUERY = `
667
+ query HttpLogs(
668
+ $environmentId: String!
669
+ $serviceId: String!
670
+ $startDate: String!
671
+ $endDate: String!
672
+ $limit: Int
673
+ ) {
674
+ httpLogs(
675
+ environmentId: $environmentId
676
+ serviceId: $serviceId
677
+ startDate: $startDate
678
+ endDate: $endDate
679
+ limit: $limit
680
+ ) {
681
+ timestamp
682
+ method
683
+ path
684
+ httpStatus
685
+ totalDuration
686
+ requestId
687
+ deploymentId
688
+ edgeRegion
689
+ }
690
+ }
691
+ `;
692
+ var NETWORK_FLOW_LOGS_QUERY = `
693
+ query NetworkFlowLogs(
694
+ $environmentId: String!
695
+ $serviceId: String!
696
+ $startDate: String!
697
+ $endDate: String!
698
+ $limit: Int
699
+ ) {
700
+ networkFlowLogs(
701
+ environmentId: $environmentId
702
+ serviceId: $serviceId
703
+ startDate: $startDate
704
+ endDate: $endDate
705
+ limit: $limit
706
+ ) {
707
+ timestamp
708
+ peerServiceId
709
+ peerKind
710
+ direction
711
+ byteCount
712
+ packetCount
713
+ dropCause
714
+ }
715
+ }
716
+ `;
717
+ async function fetchRailwayHttpLogs(config, token, startDate, endDate) {
718
+ const data = await railwayGraphQL(
719
+ config.apiUrl ?? DEFAULT_RAILWAY_API_URL,
720
+ token,
721
+ HTTP_LOGS_QUERY,
722
+ {
723
+ environmentId: config.environmentId,
724
+ serviceId: config.serviceId,
725
+ startDate,
726
+ endDate,
727
+ limit: config.limit ?? DEFAULT_LOG_LIMIT2
728
+ },
729
+ config.environmentId
730
+ );
731
+ return data.httpLogs;
732
+ }
733
+ async function fetchRailwayNetworkFlowLogs(config, token, startDate, endDate) {
734
+ const data = await railwayGraphQL(
735
+ config.apiUrl ?? DEFAULT_RAILWAY_API_URL,
736
+ token,
737
+ NETWORK_FLOW_LOGS_QUERY,
738
+ {
739
+ environmentId: config.environmentId,
740
+ serviceId: config.serviceId,
741
+ startDate,
742
+ endDate,
743
+ limit: config.limit ?? DEFAULT_LOG_LIMIT2
744
+ },
745
+ config.environmentId
746
+ );
747
+ return data.networkFlowLogs;
748
+ }
749
+ function boundedRailwayStartDate(since, now, maxLookbackMs) {
750
+ const floor = new Date(now.getTime() - maxLookbackMs);
751
+ if (!since) return floor.toISOString();
752
+ const sinceMs = new Date(since).getTime();
753
+ if (Number.isNaN(sinceMs)) return floor.toISOString();
754
+ return sinceMs < floor.getTime() ? floor.toISOString() : new Date(sinceMs).toISOString();
755
+ }
756
+
757
+ // src/connectors/railway/index.ts
758
+ var ROUTE_TARGET_KIND = "route";
759
+ var UNMATCHED_ROUTE_TARGET_KIND = "unmatched-route";
760
+ var PEER_SERVICE_TARGET_KIND = "peer-service";
761
+ function buildRailwayRouteIndex(graph, serviceName) {
762
+ const out = [];
763
+ graph.forEachNode((_id, attrs) => {
764
+ const node = attrs;
765
+ if (node.type !== NodeType.RouteNode) return;
766
+ const route = attrs;
767
+ if (route.service !== serviceName) return;
768
+ out.push({
769
+ method: route.method.toUpperCase(),
770
+ normalizedPath: normalizePathTemplate(route.pathTemplate),
771
+ routeNodeId: route.id,
772
+ path: route.path,
773
+ line: route.line
774
+ });
775
+ });
776
+ return out;
777
+ }
778
+ function findRailwayRoute(entries, method, normalizedPath) {
779
+ return entries.find(
780
+ (e) => e.normalizedPath === normalizedPath && (e.method === "ALL" || e.method === method)
781
+ );
782
+ }
783
+ function bucketKey(method, normalizedPath) {
784
+ return `${method} ${normalizedPath}`;
785
+ }
786
+ function isHttpErrorStatus(status) {
787
+ return status >= 400;
788
+ }
789
+ function upsertBucket(buckets2, key, isError, timestamp, build) {
790
+ const existing = buckets2.get(key);
791
+ if (existing) {
792
+ existing.callCount += 1;
793
+ if (isError) existing.errorCount += 1;
794
+ if (timestamp > existing.lastObservedIso) existing.lastObservedIso = timestamp;
795
+ return;
796
+ }
797
+ buckets2.set(key, { callCount: 1, errorCount: isError ? 1 : 0, lastObservedIso: timestamp, ...build() });
798
+ }
799
+ function mapRailwayHttpLogsToSignals(entries, routeIndex) {
800
+ const buckets2 = /* @__PURE__ */ new Map();
801
+ for (const entry of entries) {
802
+ const method = entry.method.toUpperCase();
803
+ const normalizedPath = normalizePathTemplate(entry.path);
804
+ const match = findRailwayRoute(routeIndex, method, normalizedPath);
805
+ const isError = isHttpErrorStatus(entry.httpStatus);
806
+ if (match) {
807
+ upsertBucket(buckets2, `route:${match.routeNodeId}`, isError, entry.timestamp, () => ({
808
+ targetKind: ROUTE_TARGET_KIND,
809
+ targetName: match.routeNodeId,
810
+ // RouteNode.line is optional in the schema (packages/types/src/
811
+ // nodes.ts) even though routes.ts always sets it today — skip the
812
+ // callSite rather than fabricate a line when it's ever absent
813
+ // (file-awareness.md §6).
814
+ ...match.line !== void 0 ? { callSite: { file: match.path, line: match.line } } : {}
815
+ }));
816
+ } else {
817
+ upsertBucket(
818
+ buckets2,
819
+ `unmatched:${bucketKey(method, normalizedPath)}`,
820
+ isError,
821
+ entry.timestamp,
822
+ () => ({
823
+ targetKind: UNMATCHED_ROUTE_TARGET_KIND,
824
+ targetName: bucketKey(method, normalizedPath)
825
+ })
826
+ );
827
+ }
828
+ }
829
+ return [...buckets2.values()].map((b) => ({
830
+ targetKind: b.targetKind,
831
+ targetName: b.targetName,
832
+ callCount: b.callCount,
833
+ errorCount: b.errorCount,
834
+ lastObservedIso: b.lastObservedIso,
835
+ ...b.callSite ? { callSite: b.callSite } : {}
836
+ }));
837
+ }
838
+ function mapRailwayNetworkFlowLogsToSignals(entries) {
839
+ const buckets2 = /* @__PURE__ */ new Map();
840
+ for (const entry of entries) {
841
+ if (!entry.peerServiceId) continue;
842
+ const isError = entry.dropCause !== null && entry.dropCause !== "";
843
+ upsertBucket(buckets2, entry.peerServiceId, isError, entry.timestamp, () => ({
844
+ targetKind: PEER_SERVICE_TARGET_KIND,
845
+ targetName: entry.peerServiceId
846
+ }));
847
+ }
848
+ return [...buckets2.values()].map((b) => ({
849
+ targetKind: b.targetKind,
850
+ targetName: b.targetName,
851
+ callCount: b.callCount,
852
+ errorCount: b.errorCount,
853
+ lastObservedIso: b.lastObservedIso
854
+ }));
855
+ }
856
+ function createRailwayResolveTarget(config) {
857
+ return (signal) => {
858
+ const serviceName = config.serviceNameById[config.serviceId];
859
+ if (!serviceName) return null;
860
+ if (signal.targetKind === ROUTE_TARGET_KIND) {
861
+ return { targetNodeId: signal.targetName, serviceName, edgeType: EdgeType2.CALLS };
862
+ }
863
+ if (signal.targetKind === PEER_SERVICE_TARGET_KIND) {
864
+ const peerName = config.serviceNameById[signal.targetName];
865
+ if (!peerName) return null;
866
+ return { targetNodeId: serviceId(peerName), serviceName, edgeType: EdgeType2.CONNECTS_TO };
867
+ }
868
+ return null;
869
+ };
870
+ }
871
+ function createRailwayConnector(graph, config) {
872
+ return {
873
+ provider: "railway",
874
+ async poll(ctx) {
875
+ const token = readRailwayToken(ctx.credentials);
876
+ const now = /* @__PURE__ */ new Date();
877
+ const maxLookbackMs = config.maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS2;
878
+ const startDate = boundedRailwayStartDate(ctx.since, now, maxLookbackMs);
879
+ const endDate = now.toISOString();
880
+ const [httpLogs, flowLogs] = await Promise.all([
881
+ fetchRailwayHttpLogs(config, token, startDate, endDate),
882
+ fetchRailwayNetworkFlowLogs(config, token, startDate, endDate)
883
+ ]);
884
+ const serviceName = config.serviceNameById[config.serviceId];
885
+ const routeIndex = serviceName ? buildRailwayRouteIndex(graph, serviceName) : [];
886
+ return [
887
+ ...mapRailwayHttpLogsToSignals(httpLogs, routeIndex),
888
+ ...mapRailwayNetworkFlowLogsToSignals(flowLogs)
889
+ ];
890
+ }
891
+ };
892
+ }
893
+
894
+ // src/connectors/firebase/logging-api.ts
895
+ function readFirebaseCredentials(raw) {
896
+ const projectId = raw["projectId"];
897
+ const accessToken = raw["accessToken"];
898
+ if (typeof projectId !== "string" || projectId.length === 0) {
899
+ throw new Error("firebase connector: credentials.projectId must be a non-empty string");
900
+ }
901
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
902
+ throw new Error("firebase connector: credentials.accessToken must be a non-empty string");
903
+ }
904
+ return { projectId, accessToken };
905
+ }
906
+ var RESOURCE_TYPES = [
907
+ "cloud_function",
908
+ "cloud_run_revision",
909
+ "firebase_domain"
910
+ ];
911
+ function isFirebaseResourceType(value) {
912
+ return RESOURCE_TYPES.includes(value);
913
+ }
914
+ function buildEntriesFilter(sinceIso) {
915
+ return [
916
+ 'resource.type = ("cloud_function" OR "cloud_run_revision" OR "firebase_domain")',
917
+ "httpRequest:*",
918
+ `timestamp >= "${sinceIso}"`
919
+ ].join(" AND ");
920
+ }
921
+ var DEFAULT_LOOKBACK_MS = 24 * 60 * 60 * 1e3;
922
+ var ENTRIES_LIST_URL = "https://logging.googleapis.com/v2/entries:list";
923
+ var PAGE_SIZE = 1e3;
924
+ var MAX_PAGES = 20;
925
+ async function fetchHttpRequestLogEntries(creds, sinceIso) {
926
+ const filter = buildEntriesFilter(sinceIso);
927
+ const out = [];
928
+ let pageToken;
929
+ for (let page = 0; page < MAX_PAGES; page++) {
930
+ const body = {
931
+ resourceNames: [`projects/${creds.projectId}`],
932
+ filter,
933
+ orderBy: "timestamp asc",
934
+ pageSize: PAGE_SIZE,
935
+ ...pageToken ? { pageToken } : {}
936
+ };
937
+ const res = await junctionFetch(
938
+ ENTRIES_LIST_URL,
939
+ {
940
+ method: "POST",
941
+ headers: {
942
+ ...bearerAuthHeader(creds.accessToken),
943
+ "Content-Type": "application/json"
944
+ },
945
+ body: JSON.stringify(body)
946
+ },
947
+ // accountKey: the GCP project id (ADR-131's own worked example for
948
+ // Firebase) — one customer's Cloud Logging quota is scoped per GCP
949
+ // project, not per Firebase site/function.
950
+ { provider: "firebase", accountKey: creds.projectId }
951
+ );
952
+ if (!res.ok) {
953
+ throw new Error(`Cloud Logging entries.list failed: ${res.status} ${res.statusText}`);
954
+ }
955
+ const json = await res.json();
956
+ out.push(...json.entries ?? []);
957
+ if (!json.nextPageToken) break;
958
+ pageToken = json.nextPageToken;
959
+ }
960
+ return out;
961
+ }
962
+
963
+ // src/connectors/firebase/map.ts
964
+ var FIELD_SEP = "\0";
965
+ function packFirebaseTargetName(identity) {
966
+ return [identity.resourceName, identity.method, identity.path].join(FIELD_SEP);
967
+ }
968
+ function parseFirebaseTargetName(targetName) {
969
+ const firstSep = targetName.indexOf(FIELD_SEP);
970
+ if (firstSep === -1) return null;
971
+ const resourceName = targetName.slice(0, firstSep);
972
+ const rest = targetName.slice(firstSep + 1);
973
+ const secondSep = rest.indexOf(FIELD_SEP);
974
+ if (secondSep === -1) return null;
975
+ const method = rest.slice(0, secondSep);
976
+ const path4 = rest.slice(secondSep + 1);
977
+ if (!resourceName || !method || !path4) return null;
978
+ return { resourceName, method, path: path4 };
979
+ }
980
+ function resourceNameFor(type, labels) {
981
+ if (!labels) return null;
982
+ switch (type) {
983
+ case "cloud_function":
984
+ return labels["function_name"] ?? null;
985
+ case "cloud_run_revision":
986
+ return labels["service_name"] ?? null;
987
+ case "firebase_domain":
988
+ return labels["site_name"] ?? null;
989
+ }
990
+ }
991
+ function pathFromRequestUrl(requestUrl) {
992
+ if (!requestUrl) return null;
993
+ if (requestUrl.startsWith("/")) {
994
+ const withoutQuery = requestUrl.split("?")[0];
995
+ return withoutQuery && withoutQuery.length > 0 ? withoutQuery : "/";
996
+ }
997
+ try {
998
+ const candidate = requestUrl.startsWith("//") ? `https:${requestUrl}` : requestUrl;
999
+ const parsed = new URL(candidate);
1000
+ return parsed.pathname || "/";
1001
+ } catch {
1002
+ return null;
1003
+ }
1004
+ }
1005
+ var ERROR_STATUS_THRESHOLD2 = 500;
1006
+ function mapLogEntryToSignal(entry) {
1007
+ const resourceType = entry.resource?.type;
1008
+ if (!resourceType || !isFirebaseResourceType(resourceType)) return null;
1009
+ const resourceName = resourceNameFor(resourceType, entry.resource?.labels);
1010
+ if (!resourceName) return null;
1011
+ const req = entry.httpRequest;
1012
+ if (!req) return null;
1013
+ if (!req.requestMethod) return null;
1014
+ const method = req.requestMethod.toUpperCase();
1015
+ const path4 = pathFromRequestUrl(req.requestUrl);
1016
+ if (path4 === null) return null;
1017
+ const timestamp = entry.timestamp;
1018
+ if (!timestamp) return null;
1019
+ const isError = typeof req.status === "number" && req.status >= ERROR_STATUS_THRESHOLD2;
1020
+ return {
1021
+ targetKind: resourceType,
1022
+ targetName: packFirebaseTargetName({ resourceName, method, path: path4 }),
1023
+ callCount: 1,
1024
+ errorCount: isError ? 1 : 0,
1025
+ lastObservedIso: timestamp
1026
+ };
1027
+ }
1028
+ function mapLogEntriesToSignals(entries) {
1029
+ const out = [];
1030
+ for (const entry of entries) {
1031
+ const signal = mapLogEntryToSignal(entry);
1032
+ if (signal) out.push(signal);
1033
+ }
1034
+ return out;
1035
+ }
1036
+
1037
+ // src/connectors/firebase/resolve.ts
1038
+ import { NodeType as NodeType2, EdgeType as EdgeType3 } from "@neat.is/types";
1039
+ function neatServiceNameFor(resourceType, resourceName, serviceMap) {
1040
+ switch (resourceType) {
1041
+ case "cloud_function":
1042
+ return serviceMap.functions?.[resourceName] ?? null;
1043
+ case "cloud_run_revision":
1044
+ return serviceMap.cloudRun?.[resourceName] ?? null;
1045
+ case "firebase_domain":
1046
+ return serviceMap.hosting?.[resourceName] ?? null;
1047
+ }
1048
+ }
1049
+ function routeEntriesFor(graph, serviceName) {
1050
+ const entries = [];
1051
+ graph.forEachNode((_id, attrs) => {
1052
+ const node = attrs;
1053
+ if (node.type !== NodeType2.RouteNode) return;
1054
+ const route = attrs;
1055
+ if (route.service !== serviceName) return;
1056
+ entries.push({
1057
+ method: route.method.toUpperCase(),
1058
+ normalizedPath: normalizePathTemplate(route.pathTemplate),
1059
+ routeNodeId: route.id
1060
+ });
1061
+ });
1062
+ return entries;
1063
+ }
1064
+ function findRoute(entries, method, normalizedPath) {
1065
+ return entries.find(
1066
+ (e) => e.normalizedPath === normalizedPath && (e.method === "ALL" || e.method === method)
1067
+ );
1068
+ }
1069
+ function createFirebaseResolveTarget(graph, serviceMap) {
1070
+ return (signal, _ctx) => {
1071
+ const resourceType = signal.targetKind;
1072
+ if (resourceType !== "cloud_function" && resourceType !== "cloud_run_revision" && resourceType !== "firebase_domain") {
1073
+ return null;
1074
+ }
1075
+ const identity = parseFirebaseTargetName(signal.targetName);
1076
+ if (!identity) return null;
1077
+ const serviceName = neatServiceNameFor(resourceType, identity.resourceName, serviceMap);
1078
+ if (!serviceName) return null;
1079
+ const normalizedPath = normalizePathTemplate(identity.path);
1080
+ const match = findRoute(routeEntriesFor(graph, serviceName), identity.method, normalizedPath);
1081
+ if (!match) return null;
1082
+ return {
1083
+ targetNodeId: match.routeNodeId,
1084
+ serviceName,
1085
+ edgeType: EdgeType3.CALLS
1086
+ };
1087
+ };
1088
+ }
1089
+
1090
+ // src/connectors/firebase/index.ts
1091
+ var FirebaseConnector = class {
1092
+ provider = "firebase";
1093
+ async poll(ctx) {
1094
+ const creds = readFirebaseCredentials(ctx.credentials);
1095
+ const sinceIso = ctx.since ?? new Date(Date.now() - DEFAULT_LOOKBACK_MS).toISOString();
1096
+ const entries = await fetchHttpRequestLogEntries(creds, sinceIso);
1097
+ return mapLogEntriesToSignals(entries);
1098
+ }
1099
+ };
1100
+ function createFirebaseConnector(graph, serviceMap) {
1101
+ return {
1102
+ connector: new FirebaseConnector(),
1103
+ resolveTarget: createFirebaseResolveTarget(graph, serviceMap)
1104
+ };
1105
+ }
1106
+
1107
+ // src/connectors/cloudflare/connector.ts
1108
+ import { EdgeType as EdgeType4, fileId } from "@neat.is/types";
1109
+
1110
+ // src/connectors/cloudflare/client.ts
1111
+ import { randomUUID } from "crypto";
1112
+ var DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4";
1113
+ var DEFAULT_EVENT_LIMIT = 1e3;
1114
+ async function queryWorkerInvocations(ctx, config, window, fetchImpl = fetch) {
1115
+ const token = ctx.credentials.apiToken;
1116
+ if (typeof token !== "string" || token.length === 0) {
1117
+ throw new Error("cloudflare connector: ctx.credentials.apiToken must be a non-empty string");
1118
+ }
1119
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
1120
+ const url = `${baseUrl}/accounts/${config.accountId}/workers/observability/telemetry/query`;
1121
+ const body = {
1122
+ // Cloudflare's schema requires an identifier per query even for an
1123
+ // ad-hoc, unsaved one — a fresh id per tick, never reused.
1124
+ queryId: `neat-connector-${randomUUID()}`,
1125
+ timeframe: { from: window.fromMs, to: window.toMs },
1126
+ view: "events",
1127
+ limit: config.eventLimit ?? DEFAULT_EVENT_LIMIT,
1128
+ // Execute without persisting — this is a read, not a saved query
1129
+ // (connectors.md §2's "never writes on the read path" applies to
1130
+ // Cloudflare's own query-history state too).
1131
+ dry: true
1132
+ };
1133
+ const res = await junctionFetch(
1134
+ url,
1135
+ {
1136
+ method: "POST",
1137
+ headers: {
1138
+ "Content-Type": "application/json",
1139
+ ...bearerAuthHeader(token)
1140
+ },
1141
+ body: JSON.stringify(body)
1142
+ },
1143
+ // accountKey: the Cloudflare account id (ADR-131's own worked example) —
1144
+ // the Telemetry Query API's ~300/5min limit is enforced per account.
1145
+ { provider: "cloudflare", accountKey: config.accountId, fetchImpl }
1146
+ );
1147
+ if (!res.ok) {
1148
+ throw new Error(
1149
+ `cloudflare connector: telemetry query failed (${res.status} ${res.statusText})`
1150
+ );
1151
+ }
1152
+ const payload = await res.json();
1153
+ if (!payload.success) {
1154
+ const message = payload.errors?.map((e) => e.message).join("; ") || "unknown error";
1155
+ throw new Error(`cloudflare connector: telemetry query returned an error (${message})`);
1156
+ }
1157
+ return payload.result?.events?.events ?? [];
1158
+ }
1159
+
1160
+ // src/connectors/cloudflare/types.ts
1161
+ var CLOUDFLARE_TARGET_KIND = "cloudflare-worker-invocation";
1162
+
1163
+ // src/connectors/cloudflare/map.ts
1164
+ var HTTP_METHODS = /* @__PURE__ */ new Set([
1165
+ "GET",
1166
+ "POST",
1167
+ "PUT",
1168
+ "PATCH",
1169
+ "DELETE",
1170
+ "HEAD",
1171
+ "OPTIONS",
1172
+ "TRACE",
1173
+ "CONNECT"
1174
+ ]);
1175
+ var LEADING_TOKEN_RE = /^(\S+)\s+\S/;
1176
+ function parseHttpMethodFromTrigger(trigger) {
1177
+ if (!trigger) return null;
1178
+ const match = LEADING_TOKEN_RE.exec(trigger.trim());
1179
+ const token = match?.[1];
1180
+ if (!token) return null;
1181
+ const method = token.toUpperCase();
1182
+ return HTTP_METHODS.has(method) ? method : null;
1183
+ }
1184
+ var ERROR_STATUS_THRESHOLD3 = 500;
1185
+ function mapEventToSignal(event) {
1186
+ const metadata = event.$metadata;
1187
+ const workers = event.$workers;
1188
+ const method = parseHttpMethodFromTrigger(metadata?.trigger);
1189
+ if (!method) return null;
1190
+ const scriptName = workers?.scriptName ?? metadata?.service;
1191
+ if (!scriptName) return null;
1192
+ const timestampMs = event.timestamp ?? metadata?.startTime;
1193
+ if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return null;
1194
+ const statusCode = metadata?.statusCode;
1195
+ const isError = typeof statusCode === "number" && statusCode >= ERROR_STATUS_THRESHOLD3;
1196
+ return {
1197
+ targetKind: CLOUDFLARE_TARGET_KIND,
1198
+ targetName: scriptName,
1199
+ callCount: 1,
1200
+ errorCount: isError ? 1 : 0,
1201
+ lastObservedIso: new Date(timestampMs).toISOString(),
1202
+ method,
1203
+ ...typeof statusCode === "number" ? { statusCode } : {},
1204
+ ...typeof metadata?.duration === "number" ? { duration: metadata.duration } : {}
1205
+ };
1206
+ }
1207
+
1208
+ // src/connectors/cloudflare/connector.ts
1209
+ var DEFAULT_MAX_LOOKBACK_MS3 = 60 * 60 * 1e3;
1210
+ function resolveFromMs(since, maxLookbackMs) {
1211
+ const cap = maxLookbackMs ?? DEFAULT_MAX_LOOKBACK_MS3;
1212
+ const now = Date.now();
1213
+ const floor = now - cap;
1214
+ if (!since) return floor;
1215
+ const parsed = Date.parse(since);
1216
+ if (Number.isNaN(parsed)) return floor;
1217
+ return Math.max(parsed, floor);
1218
+ }
1219
+ var CloudflareConnector = class {
1220
+ constructor(config) {
1221
+ this.config = config;
1222
+ }
1223
+ config;
1224
+ provider = "cloudflare";
1225
+ async poll(ctx) {
1226
+ const toMs = Date.now();
1227
+ const fromMs = resolveFromMs(ctx.since, this.config.maxLookbackMs);
1228
+ const events = await queryWorkerInvocations(ctx, this.config, { fromMs, toMs });
1229
+ const signals = [];
1230
+ for (const event of events) {
1231
+ const signal = mapEventToSignal(event);
1232
+ if (signal) signals.push(signal);
1233
+ }
1234
+ return signals;
1235
+ }
1236
+ };
1237
+ function createCloudflareResolveTarget(config) {
1238
+ return (signal) => {
1239
+ if (signal.targetKind !== CLOUDFLARE_TARGET_KIND) return null;
1240
+ const mapping = config.workers[signal.targetName];
1241
+ if (!mapping) return null;
1242
+ return {
1243
+ targetNodeId: fileId(mapping.service, mapping.entryFile),
1244
+ serviceName: mapping.service,
1245
+ edgeType: EdgeType4.CALLS
1246
+ };
1247
+ };
1248
+ }
1249
+
1250
+ // src/connectors-config.ts
1251
+ import os from "os";
1252
+ import path from "path";
1253
+ import { promises as fs } from "fs";
1254
+ var CONNECTORS_CONFIG_VERSION = 1;
1255
+ var EnvRefUnsetError = class extends Error {
1256
+ ref;
1257
+ varName;
1258
+ constructor(ref, varName) {
1259
+ super(`${ref} is unset`);
1260
+ this.name = "EnvRefUnsetError";
1261
+ this.ref = ref;
1262
+ this.varName = varName;
1263
+ }
1264
+ };
1265
+ function neatHome() {
1266
+ const override = process.env.NEAT_HOME;
1267
+ if (override && override.length > 0) return path.resolve(override);
1268
+ return path.join(os.homedir(), ".neat");
1269
+ }
1270
+ function connectorsConfigPath(home = neatHome()) {
1271
+ return path.join(home, "connectors.json");
1272
+ }
1273
+ var MODE_MASK_LOOSER_THAN_0600 = 63;
1274
+ async function warnIfModeLooserThan0600(file) {
1275
+ if (process.platform === "win32") return;
1276
+ try {
1277
+ const stat = await fs.stat(file);
1278
+ if ((stat.mode & MODE_MASK_LOOSER_THAN_0600) !== 0) {
1279
+ const mode = (stat.mode & 511).toString(8).padStart(3, "0");
1280
+ console.warn(
1281
+ `[neat] ${file} is mode 0${mode}, looser than the 0600 this file's secrets call for \u2014 run \`chmod 600 ${file}\``
1282
+ );
1283
+ }
1284
+ } catch {
1285
+ }
1286
+ }
1287
+ async function readConnectorsConfig(home = neatHome()) {
1288
+ const file = connectorsConfigPath(home);
1289
+ let raw;
1290
+ try {
1291
+ raw = await fs.readFile(file, "utf8");
1292
+ } catch (err) {
1293
+ if (err.code === "ENOENT") {
1294
+ return { version: CONNECTORS_CONFIG_VERSION, connectors: [] };
1295
+ }
1296
+ throw err;
1297
+ }
1298
+ await warnIfModeLooserThan0600(file);
1299
+ let parsed;
1300
+ try {
1301
+ parsed = JSON.parse(raw);
1302
+ } catch (err) {
1303
+ throw new Error(`${file} is not valid JSON: ${err.message}`);
1304
+ }
1305
+ return validateConfig(parsed, file);
1306
+ }
1307
+ function validateConfig(parsed, file) {
1308
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1309
+ throw new Error(`${file} must be a JSON object with a "connectors" array`);
1310
+ }
1311
+ const obj = parsed;
1312
+ const version = obj.version === void 0 ? CONNECTORS_CONFIG_VERSION : obj.version;
1313
+ if (typeof version !== "number" || !Number.isInteger(version)) {
1314
+ throw new Error(`${file}: "version" must be an integer`);
1315
+ }
1316
+ const rawConnectors = obj.connectors;
1317
+ if (!Array.isArray(rawConnectors)) {
1318
+ throw new Error(`${file}: "connectors" must be an array`);
1319
+ }
1320
+ const connectors = rawConnectors.map((entry, i) => validateEntry(entry, i, file));
1321
+ return { version, connectors };
1322
+ }
1323
+ function validateEntry(entry, index, file) {
1324
+ const where = `${file}: connectors[${index}]`;
1325
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
1326
+ throw new Error(`${where} must be an object`);
1327
+ }
1328
+ const e = entry;
1329
+ const id = e.id;
1330
+ if (typeof id !== "string" || id.length === 0) {
1331
+ throw new Error(`${where}.id must be a non-empty string`);
1332
+ }
1333
+ const provider = e.provider;
1334
+ if (typeof provider !== "string" || provider.length === 0) {
1335
+ throw new Error(`${where}.provider must be a non-empty string`);
1336
+ }
1337
+ if (e.project !== void 0 && typeof e.project !== "string") {
1338
+ throw new Error(`${where}.project must be a string when present`);
1339
+ }
1340
+ const credential = validateCredentialRef(e.credential, `${where}.credential`);
1341
+ let options;
1342
+ if (e.options !== void 0) {
1343
+ if (typeof e.options !== "object" || e.options === null || Array.isArray(e.options)) {
1344
+ throw new Error(`${where}.options must be an object when present`);
1345
+ }
1346
+ options = e.options;
1347
+ }
1348
+ return {
1349
+ id,
1350
+ provider,
1351
+ ...typeof e.project === "string" ? { project: e.project } : {},
1352
+ credential,
1353
+ ...options ? { options } : {}
1354
+ };
1355
+ }
1356
+ function validateCredentialRef(raw, where) {
1357
+ if (typeof raw === "string") {
1358
+ if (raw.length === 0) throw new Error(`${where} must be a non-empty string`);
1359
+ return raw;
1360
+ }
1361
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
1362
+ const fields = raw;
1363
+ const keys = Object.keys(fields);
1364
+ if (keys.length === 0) throw new Error(`${where} object must have at least one field`);
1365
+ for (const key of keys) {
1366
+ const v = fields[key];
1367
+ if (typeof v !== "string" || v.length === 0) {
1368
+ throw new Error(`${where}.${key} must be a non-empty string`);
1369
+ }
1370
+ }
1371
+ return fields;
1372
+ }
1373
+ throw new Error(`${where} must be a string or an object of strings`);
1374
+ }
1375
+ function resolveCredential(ref, env = process.env) {
1376
+ if (typeof ref === "string") {
1377
+ return { kind: "single", value: resolveRef(ref, env) };
1378
+ }
1379
+ const fields = {};
1380
+ for (const [key, value] of Object.entries(ref)) {
1381
+ fields[key] = resolveRef(value, env);
1382
+ }
1383
+ return { kind: "fields", fields };
1384
+ }
1385
+ function resolveRef(value, env) {
1386
+ if (value.length > 1 && value.startsWith("$")) {
1387
+ const varName = value.slice(1);
1388
+ const resolved = env[varName];
1389
+ if (resolved === void 0 || resolved.length === 0) {
1390
+ throw new EnvRefUnsetError(value, varName);
1391
+ }
1392
+ return resolved;
1393
+ }
1394
+ return value;
1395
+ }
1396
+ function connectorMatchesProject(entry, project) {
1397
+ return entry.project === void 0 || entry.project === project;
1398
+ }
1399
+ var CONNECTORS_LOCK_TIMEOUT_MS = 5e3;
1400
+ var CONNECTORS_LOCK_RETRY_MS = 50;
1401
+ function connectorsConfigLockPath(home = neatHome()) {
1402
+ return path.join(home, "connectors.json.lock");
1403
+ }
1404
+ function isEnvRef(value) {
1405
+ return value.length > 1 && value.startsWith("$");
1406
+ }
1407
+ async function writeConfigAtomically0600(file, contents) {
1408
+ await fs.mkdir(path.dirname(file), { recursive: true });
1409
+ const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
1410
+ const fd = await fs.open(tmp, "w", 384);
1411
+ try {
1412
+ await fd.writeFile(contents, "utf8");
1413
+ await fd.chmod(384);
1414
+ await fd.sync();
1415
+ } finally {
1416
+ await fd.close();
1417
+ }
1418
+ await fs.rename(tmp, file);
1419
+ }
1420
+ async function acquireConnectorsLock(lockPath, timeoutMs = CONNECTORS_LOCK_TIMEOUT_MS) {
1421
+ const deadline = Date.now() + timeoutMs;
1422
+ await fs.mkdir(path.dirname(lockPath), { recursive: true });
1423
+ for (; ; ) {
1424
+ try {
1425
+ const fd = await fs.open(lockPath, "wx");
1426
+ try {
1427
+ await fd.writeFile(`${process.pid}
1428
+ `, "utf8");
1429
+ } finally {
1430
+ await fd.close();
1431
+ }
1432
+ return;
1433
+ } catch (err) {
1434
+ if (err.code !== "EEXIST") throw err;
1435
+ if (Date.now() >= deadline) {
1436
+ throw new Error(
1437
+ `neat connector: timed out after ${timeoutMs}ms waiting for ${lockPath}. Another neat process may be writing the connector config; if none is, remove that file by hand.`
1438
+ );
1439
+ }
1440
+ await new Promise((r) => setTimeout(r, CONNECTORS_LOCK_RETRY_MS));
1441
+ }
1442
+ }
1443
+ }
1444
+ async function releaseConnectorsLock(lockPath) {
1445
+ await fs.unlink(lockPath).catch(() => {
1446
+ });
1447
+ }
1448
+ async function withConnectorsLock(home, fn) {
1449
+ const lock = connectorsConfigLockPath(home);
1450
+ await acquireConnectorsLock(lock);
1451
+ try {
1452
+ return await fn();
1453
+ } finally {
1454
+ await releaseConnectorsLock(lock);
1455
+ }
1456
+ }
1457
+ function serializeConfig(config) {
1458
+ return JSON.stringify(
1459
+ { version: config.version ?? CONNECTORS_CONFIG_VERSION, connectors: config.connectors },
1460
+ null,
1461
+ 2
1462
+ ) + "\n";
1463
+ }
1464
+ async function upsertConnectorEntry(entry, home = neatHome()) {
1465
+ const file = connectorsConfigPath(home);
1466
+ return withConnectorsLock(home, async () => {
1467
+ const config = await readConnectorsConfig(home);
1468
+ validateEntry(entry, config.connectors.length, file);
1469
+ const idx = config.connectors.findIndex((c) => c.id === entry.id);
1470
+ const replaced = idx >= 0;
1471
+ if (replaced) config.connectors[idx] = entry;
1472
+ else config.connectors.push(entry);
1473
+ await writeConfigAtomically0600(file, serializeConfig(config));
1474
+ return { replaced };
1475
+ });
1476
+ }
1477
+ async function removeConnectorEntry(id, home = neatHome()) {
1478
+ const file = connectorsConfigPath(home);
1479
+ return withConnectorsLock(home, async () => {
1480
+ const config = await readConnectorsConfig(home);
1481
+ const idx = config.connectors.findIndex((c) => c.id === id);
1482
+ if (idx < 0) return void 0;
1483
+ const [removed] = config.connectors.splice(idx, 1);
1484
+ await writeConfigAtomically0600(file, serializeConfig(config));
1485
+ return removed;
1486
+ });
1487
+ }
1488
+ function slugFragment(value) {
1489
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1490
+ }
1491
+ function autoSlugConnectorId(provider, project, existingIds) {
1492
+ const base = slugFragment(provider) || "connector";
1493
+ if (!existingIds.has(base)) return base;
1494
+ const projectFrag = project ? slugFragment(project) : "";
1495
+ if (projectFrag) {
1496
+ const withProject = `${base}-${projectFrag}`;
1497
+ if (!existingIds.has(withProject)) return withProject;
1498
+ }
1499
+ const stem = projectFrag ? `${base}-${projectFrag}` : base;
1500
+ for (let n = 2; ; n++) {
1501
+ const candidate = `${stem}-${n}`;
1502
+ if (!existingIds.has(candidate)) return candidate;
1503
+ }
1504
+ }
1505
+ function describeCredential(ref, env = process.env) {
1506
+ const one = (value, field) => {
1507
+ if (isEnvRef(value)) {
1508
+ const varName = value.slice(1);
1509
+ const resolved = env[varName];
1510
+ const set = typeof resolved === "string" && resolved.length > 0;
1511
+ return {
1512
+ ...field ? { field } : {},
1513
+ kind: "env-ref",
1514
+ display: value,
1515
+ status: set ? "set" : "unset"
1516
+ };
1517
+ }
1518
+ return { ...field ? { field } : {}, kind: "plaintext", display: "****" };
1519
+ };
1520
+ if (typeof ref === "string") return [one(ref)];
1521
+ return Object.entries(ref).map(([k, v]) => one(v, k));
1522
+ }
1523
+
1524
+ // src/connectors/registry.ts
1525
+ var CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
1526
+ async function authProbe(input) {
1527
+ const { provider, accountKey, url, token, init, fetchImpl } = input;
1528
+ try {
1529
+ const res = await junctionFetch(
1530
+ url,
1531
+ {
1532
+ ...init ?? {},
1533
+ headers: { ...bearerAuthHeader(token), ...init?.headers ?? {} }
1534
+ },
1535
+ { provider, accountKey, ...fetchImpl ? { fetchImpl } : {} }
1536
+ );
1537
+ if (res.ok) return { ok: true };
1538
+ if (res.status === 401 || res.status === 403) {
1539
+ return { ok: false, reason: `${provider} rejected the credential (HTTP ${res.status})` };
1540
+ }
1541
+ return {
1542
+ ok: false,
1543
+ reason: `${provider} auth check returned HTTP ${res.status} ${res.statusText} \u2014 could not confirm the credential`
1544
+ };
1545
+ } catch (err) {
1546
+ return {
1547
+ ok: false,
1548
+ reason: `${provider} auth check could not reach the provider: ${err.message}`
1549
+ };
1550
+ }
1551
+ }
1552
+ var PROVIDER_DISPATCH = {
1553
+ supabase: {
1554
+ provider: "supabase",
1555
+ primaryCredentialKey: "managementToken",
1556
+ requiredCredentialFields: ["managementToken"],
1557
+ requiredOptionFields: ["apiProjectRef", "nodeRef", "serviceName"],
1558
+ build(graph, options) {
1559
+ return createSupabaseConnector(graph, options);
1560
+ },
1561
+ // GET /v1/projects — the Management API's own auth-gated list endpoint, the
1562
+ // cheapest confirmation the management token is live (the same surface
1563
+ // client.ts polls, minus the heavy log query).
1564
+ validate({ credentials, options, fetchImpl }) {
1565
+ const cfg = options;
1566
+ const baseUrl = cfg.managementApiUrl ?? DEFAULT_SUPABASE_MANAGEMENT_API_URL;
1567
+ return authProbe({
1568
+ provider: "supabase",
1569
+ accountKey: cfg.apiProjectRef ?? "validate",
1570
+ url: `${baseUrl}/v1/projects`,
1571
+ token: String(credentials.managementToken ?? ""),
1572
+ ...fetchImpl ? { fetchImpl } : {}
1573
+ });
1574
+ }
1575
+ },
1576
+ railway: {
1577
+ provider: "railway",
1578
+ primaryCredentialKey: "token",
1579
+ requiredCredentialFields: ["token"],
1580
+ requiredOptionFields: ["environmentId", "serviceId", "serviceNameById"],
1581
+ build(graph, options) {
1582
+ const config = options;
1583
+ return {
1584
+ connector: createRailwayConnector(graph, config),
1585
+ resolveTarget: createRailwayResolveTarget(config)
1586
+ };
1587
+ },
1588
+ // A minimal GraphQL POST — Railway's gateway 401s an unauthenticated call
1589
+ // at the HTTP layer, so a 2xx confirms the token was accepted regardless of
1590
+ // the query's own shape (the connector's real queries are still
1591
+ // needs-endpoint-testing per railway/types.ts — auth is what we check).
1592
+ validate({ credentials, options, fetchImpl }) {
1593
+ const cfg = options;
1594
+ return authProbe({
1595
+ provider: "railway",
1596
+ accountKey: cfg.environmentId ?? "validate",
1597
+ url: cfg.apiUrl ?? DEFAULT_RAILWAY_API_URL,
1598
+ token: String(credentials.token ?? ""),
1599
+ init: {
1600
+ method: "POST",
1601
+ headers: { "Content-Type": "application/json" },
1602
+ body: JSON.stringify({ query: "{ __typename }" })
1603
+ },
1604
+ ...fetchImpl ? { fetchImpl } : {}
1605
+ });
1606
+ }
1607
+ },
1608
+ firebase: {
1609
+ provider: "firebase",
1610
+ // Firebase reads both projectId and accessToken from the credential; the
1611
+ // single-string form maps to the secret, and the required-fields check
1612
+ // below catches a projectId that was never supplied.
1613
+ primaryCredentialKey: "accessToken",
1614
+ requiredCredentialFields: ["projectId", "accessToken"],
1615
+ requiredOptionFields: [],
1616
+ build(graph, options) {
1617
+ return createFirebaseConnector(graph, options);
1618
+ },
1619
+ // GET the project's Cloud Logging log-name list (pageSize 1) — within the
1620
+ // same `roles/logging.viewer` grant the connector polls under, and the
1621
+ // lightest call that still fails 401/403 on a bad or wrong-scoped token.
1622
+ validate({ credentials, fetchImpl }) {
1623
+ const projectId = String(credentials.projectId ?? "");
1624
+ return authProbe({
1625
+ provider: "firebase",
1626
+ accountKey: projectId || "validate",
1627
+ url: `https://logging.googleapis.com/v2/projects/${projectId}/logs?pageSize=1`,
1628
+ token: String(credentials.accessToken ?? ""),
1629
+ ...fetchImpl ? { fetchImpl } : {}
1630
+ });
1631
+ }
1632
+ },
1633
+ cloudflare: {
1634
+ provider: "cloudflare",
1635
+ primaryCredentialKey: "apiToken",
1636
+ requiredCredentialFields: ["apiToken"],
1637
+ requiredOptionFields: ["accountId", "workers"],
1638
+ build(_graph, options) {
1639
+ const config = options;
1640
+ return {
1641
+ connector: new CloudflareConnector(config),
1642
+ resolveTarget: createCloudflareResolveTarget(config)
1643
+ };
1644
+ },
1645
+ // GET /user/tokens/verify — Cloudflare's own purpose-built "is this API
1646
+ // token live" endpoint. 200 on a valid token, 401 on an invalid one.
1647
+ validate({ credentials, options, fetchImpl }) {
1648
+ const cfg = options;
1649
+ const baseUrl = cfg.baseUrl ?? CLOUDFLARE_API_BASE_URL;
1650
+ return authProbe({
1651
+ provider: "cloudflare",
1652
+ accountKey: cfg.accountId ?? "validate",
1653
+ url: `${baseUrl}/user/tokens/verify`,
1654
+ token: String(credentials.apiToken ?? ""),
1655
+ ...fetchImpl ? { fetchImpl } : {}
1656
+ });
1657
+ }
1658
+ }
1659
+ };
1660
+ function getProviderDispatch(provider) {
1661
+ return PROVIDER_DISPATCH[provider];
1662
+ }
1663
+ function resolveEntryCredentials(dispatch, entry, env) {
1664
+ let credentials;
1665
+ try {
1666
+ const resolved = resolveCredential(entry.credential, env);
1667
+ credentials = resolved.kind === "single" ? { [dispatch.primaryCredentialKey]: resolved.value } : { ...resolved.fields };
1668
+ } catch (err) {
1669
+ if (err instanceof EnvRefUnsetError) return { ok: false, kind: "unset-env", reason: err.message };
1670
+ return { ok: false, kind: "error", reason: err.message };
1671
+ }
1672
+ const missingCreds = dispatch.requiredCredentialFields.filter((k) => !credentials[k]);
1673
+ if (missingCreds.length > 0) {
1674
+ return {
1675
+ ok: false,
1676
+ kind: "missing-field",
1677
+ reason: `credential missing required field(s): ${missingCreds.join(", ")}`
1678
+ };
1679
+ }
1680
+ return { ok: true, credentials };
1681
+ }
1682
+ function buildRegistration(entry, graph, env = process.env) {
1683
+ const dispatch = PROVIDER_DISPATCH[entry.provider];
1684
+ if (!dispatch) {
1685
+ return { ok: false, reason: `unknown provider "${entry.provider}"` };
1686
+ }
1687
+ const creds = resolveEntryCredentials(dispatch, entry, env);
1688
+ if (!creds.ok) return { ok: false, reason: creds.reason };
1689
+ const credentials = creds.credentials;
1690
+ const options = entry.options ?? {};
1691
+ const missingOpts = dispatch.requiredOptionFields.filter((k) => !(k in options));
1692
+ if (missingOpts.length > 0) {
1693
+ return {
1694
+ ok: false,
1695
+ reason: `options missing required field(s): ${missingOpts.join(", ")}`
1696
+ };
1697
+ }
1698
+ let built;
1699
+ try {
1700
+ built = dispatch.build(graph, options);
1701
+ } catch (err) {
1702
+ return { ok: false, reason: err.message };
1703
+ }
1704
+ const intervalMs = typeof options.intervalMs === "number" ? options.intervalMs : void 0;
1705
+ return {
1706
+ ok: true,
1707
+ registration: {
1708
+ connector: built.connector,
1709
+ credentials,
1710
+ resolveTarget: built.resolveTarget,
1711
+ ...intervalMs !== void 0 ? { intervalMs } : {}
1712
+ }
1713
+ };
1714
+ }
1715
+ async function validateConnectorEntry(entry, env = process.env, fetchImpl) {
1716
+ const dispatch = PROVIDER_DISPATCH[entry.provider];
1717
+ if (!dispatch) {
1718
+ return { status: "unknown-provider", reason: `unknown provider "${entry.provider}"` };
1719
+ }
1720
+ const creds = resolveEntryCredentials(dispatch, entry, env);
1721
+ if (!creds.ok) {
1722
+ if (creds.kind === "unset-env") return { status: "unset-env", reason: creds.reason };
1723
+ return { status: "missing-field", reason: creds.reason };
1724
+ }
1725
+ const options = entry.options ?? {};
1726
+ const missingOpts = dispatch.requiredOptionFields.filter((k) => !(k in options));
1727
+ if (missingOpts.length > 0) {
1728
+ return {
1729
+ status: "missing-field",
1730
+ reason: `options missing required field(s): ${missingOpts.join(", ")}`
1731
+ };
1732
+ }
1733
+ const result = await dispatch.validate({
1734
+ credentials: creds.credentials,
1735
+ options,
1736
+ ...fetchImpl ? { fetchImpl } : {}
1737
+ });
1738
+ return result.ok ? { status: "ok" } : { status: "rejected", reason: result.reason };
1739
+ }
1740
+ async function loadConnectorRegistrations(input) {
1741
+ const { project, graph, home, env = process.env, onSkip } = input;
1742
+ let connectors;
1743
+ try {
1744
+ connectors = (await readConnectorsConfig(home)).connectors;
1745
+ } catch (err) {
1746
+ onSkip?.(
1747
+ { id: "(file)", provider: "(all)", credential: "" },
1748
+ `connectors.json unreadable \u2014 ${err.message}`
1749
+ );
1750
+ return [];
1751
+ }
1752
+ const registrations = [];
1753
+ for (const entry of connectors) {
1754
+ if (!connectorMatchesProject(entry, project)) continue;
1755
+ const result = buildRegistration(entry, graph, env);
1756
+ if (result.ok) registrations.push(result.registration);
1757
+ else onSkip?.(entry, result.reason);
1758
+ }
1759
+ return registrations;
1760
+ }
1761
+
1762
+ // src/unrouted.ts
1763
+ import { promises as fs2 } from "fs";
1764
+ import path2 from "path";
1765
+ function buildUnroutedSpanRecord(serviceName, traceId, now = /* @__PURE__ */ new Date()) {
1766
+ return {
1767
+ timestamp: now.toISOString(),
1768
+ reason: "no-project-match",
1769
+ service_name: serviceName ?? null,
1770
+ traceId: traceId ?? null
1771
+ };
1772
+ }
1773
+ async function appendUnroutedSpan(neatHome2, record) {
1774
+ const target = path2.join(neatHome2, "errors.ndjson");
1775
+ await fs2.mkdir(neatHome2, { recursive: true });
1776
+ await fs2.appendFile(target, JSON.stringify(record) + "\n", "utf8");
1777
+ }
1778
+ function unroutedErrorsPath(neatHome2) {
1779
+ return path2.join(neatHome2, "errors.ndjson");
1780
+ }
1781
+
1782
+ // src/daemon.ts
1783
+ import { NodeType as NodeType3 } from "@neat.is/types";
1784
+ function daemonJsonPath(scanPath) {
1785
+ return path3.join(scanPath, "neat-out", "daemon.json");
1786
+ }
1787
+ function daemonsDiscoveryDir(home) {
1788
+ const base = home && home.length > 0 ? home : neatHomeFromEnv();
1789
+ return path3.join(base, "daemons");
1790
+ }
1791
+ function daemonDiscoveryPath(project, home) {
1792
+ return path3.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
1793
+ }
1794
+ function sanitizeDiscoveryName(project) {
1795
+ return project.replace(/[^A-Za-z0-9._-]/g, "_");
1796
+ }
1797
+ function neatHomeFromEnv() {
1798
+ const env = process.env.NEAT_HOME;
1799
+ if (env && env.length > 0) return path3.resolve(env);
1800
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
1801
+ return path3.join(home, ".neat");
1802
+ }
1803
+ async function readDaemonRecord(scanPath) {
1804
+ try {
1805
+ const raw = await fs3.readFile(daemonJsonPath(scanPath), "utf8");
1806
+ const parsed = JSON.parse(raw);
1807
+ if (typeof parsed.project === "string" && parsed.ports && typeof parsed.ports.rest === "number" && typeof parsed.ports.otlp === "number" && typeof parsed.ports.web === "number") {
1808
+ return parsed;
1809
+ }
1810
+ return null;
1811
+ } catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+ function resolveNeatVersion() {
1816
+ if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
1817
+ return process.env.NEAT_LOCAL_VERSION;
1818
+ }
1819
+ try {
1820
+ const req = createRequire(import.meta.url);
1821
+ const pkg = req("../package.json");
1822
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
1823
+ } catch {
1824
+ return "0.0.0";
1825
+ }
1826
+ }
1827
+ async function writeDaemonRecord(record, home) {
1828
+ const body = JSON.stringify(record, null, 2) + "\n";
1829
+ await writeAtomically(daemonJsonPath(record.projectPath), body);
1830
+ try {
1831
+ await writeAtomically(daemonDiscoveryPath(record.project, home), body);
1832
+ } catch (err) {
1833
+ console.warn(
1834
+ `neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
1835
+ );
1836
+ }
1837
+ }
1838
+ async function clearDaemonRecord(record, home) {
1839
+ try {
1840
+ const stopped = { ...record, status: "stopped" };
1841
+ await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
1842
+ } catch {
1843
+ }
1844
+ try {
1845
+ await fs3.unlink(daemonDiscoveryPath(record.project, home));
1846
+ } catch {
1847
+ }
1848
+ }
1849
+ function reconcileDaemonRecordSync(record, home) {
1850
+ try {
1851
+ const stopped = { ...record, status: "stopped" };
1852
+ const target = daemonJsonPath(record.projectPath);
1853
+ const tmp = `${target}.${process.pid}.tmp`;
1854
+ writeFileSync(tmp, JSON.stringify(stopped, null, 2) + "\n");
1855
+ renameSync(tmp, target);
1856
+ } catch {
1857
+ }
1858
+ try {
1859
+ unlinkSync(daemonDiscoveryPath(record.project, home));
1860
+ } catch {
1861
+ }
1862
+ }
1863
+ function teardownSlot(slot) {
1864
+ try {
1865
+ slot.stopPersist();
1866
+ } catch {
1867
+ }
1868
+ try {
1869
+ slot.stopStaleness();
1870
+ } catch {
1871
+ }
1872
+ try {
1873
+ slot.stopConnectors();
1874
+ } catch {
1875
+ }
1876
+ try {
1877
+ slot.detachEvents();
1878
+ } catch {
1879
+ }
1880
+ }
1881
+ function neatHomeFor(opts) {
1882
+ if (opts.neatHome && opts.neatHome.length > 0) return path3.resolve(opts.neatHome);
1883
+ const env = process.env.NEAT_HOME;
1884
+ if (env && env.length > 0) return path3.resolve(env);
1885
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
1886
+ return path3.join(home, ".neat");
1887
+ }
1888
+ function routeSpanToProject(serviceName, projects) {
1889
+ if (!serviceName) return DEFAULT_PROJECT;
1890
+ for (const entry of projects) {
1891
+ if (entry.status === "paused") continue;
1892
+ if (entry.name === serviceName) return entry.name;
1893
+ }
1894
+ const candidates = [];
1895
+ for (const entry of projects) {
1896
+ if (entry.status === "paused") continue;
1897
+ if (isTokenPrefix(entry.name, serviceName)) candidates.push(entry);
1898
+ }
1899
+ if (candidates.length > 0) {
1900
+ candidates.sort((a, b) => b.name.length - a.name.length);
1901
+ return candidates[0].name;
1902
+ }
1903
+ for (const entry of projects) {
1904
+ if (entry.status === "paused") continue;
1905
+ if (isTokenContained(entry.name, serviceName)) return entry.name;
1906
+ }
1907
+ return DEFAULT_PROJECT;
1908
+ }
1909
+ function isTokenPrefix(prefix, full) {
1910
+ if (prefix.length >= full.length) return false;
1911
+ if (!full.startsWith(prefix)) return false;
1912
+ const sep = full.charAt(prefix.length);
1913
+ return sep === "-" || sep === "_";
1914
+ }
1915
+ function isTokenContained(needle, haystack) {
1916
+ if (!haystack.includes(needle)) return false;
1917
+ const tokens = haystack.split(/[-_]/);
1918
+ return tokens.includes(needle);
1919
+ }
1920
+ function serviceNameMatchesProject(serviceName, project) {
1921
+ if (serviceName === project) return true;
1922
+ if (isTokenPrefix(project, serviceName)) return true;
1923
+ if (isTokenContained(project, serviceName)) return true;
1924
+ return false;
1925
+ }
1926
+ function spanBelongsToSingleProject(graph, project, serviceName) {
1927
+ if (!serviceName) return true;
1928
+ if (serviceNameMatchesProject(serviceName, project)) return true;
1929
+ return graph.someNode(
1930
+ (_id, attrs) => attrs.type === NodeType3.ServiceNode && attrs.name === serviceName
1931
+ );
1932
+ }
1933
+ async function bootstrapProject(entry, connectors = [], neatHome2) {
1934
+ const paths = pathsForProject(entry.name, path3.join(entry.path, "neat-out"));
1935
+ try {
1936
+ const stat = await fs3.stat(entry.path);
1937
+ if (!stat.isDirectory()) {
1938
+ throw new Error(`registered path ${entry.path} is not a directory`);
1939
+ }
1940
+ } catch (err) {
1941
+ await setStatus(entry.name, "broken").catch(() => {
1942
+ });
1943
+ return {
1944
+ entry,
1945
+ // Empty graph is fine — `slots` keeps the entry visible in `status`
1946
+ // output; nothing routes to it because it's not 'active'.
1947
+ graph: getGraph(`__broken__:${entry.name}`),
1948
+ outPath: "",
1949
+ paths,
1950
+ stopPersist: () => {
1951
+ },
1952
+ stopStaleness: () => {
1953
+ },
1954
+ stopConnectors: () => {
1955
+ },
1956
+ detachEvents: () => {
1957
+ },
1958
+ status: "broken",
1959
+ errorReason: err.message
1960
+ };
1961
+ }
1962
+ resetGraph(entry.name);
1963
+ const graph = getGraph(entry.name);
1964
+ const outPath = paths.snapshotPath;
1965
+ await loadGraphFromDisk(graph, outPath);
1966
+ const detachEvents = attachGraphToEventBus(graph, { project: entry.name });
1967
+ try {
1968
+ await extractFromDirectory(graph, entry.path);
1969
+ const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
1970
+ const stopStaleness = startStalenessLoop(graph, {
1971
+ staleEventsPath: paths.staleEventsPath,
1972
+ project: entry.name
1973
+ });
1974
+ const fileConnectors = neatHome2 ? await loadConnectorRegistrations({
1975
+ project: entry.name,
1976
+ graph,
1977
+ home: neatHome2,
1978
+ onSkip: (skipped, reason) => console.warn(
1979
+ `neatd: connector "${skipped.id}" (${skipped.provider}) skipped for project "${entry.name}" \u2014 ${reason}`
1980
+ )
1981
+ }) : [];
1982
+ const allConnectors = [...connectors, ...fileConnectors];
1983
+ const stopFns = allConnectors.map(
1984
+ (registration) => startConnectorPollLoop(
1985
+ registration.connector,
1986
+ { projectDir: entry.path, credentials: registration.credentials },
1987
+ graph,
1988
+ registration.resolveTarget,
1989
+ { intervalMs: registration.intervalMs }
1990
+ )
1991
+ );
1992
+ const stopConnectors = () => {
1993
+ for (const stop of stopFns) stop();
1994
+ };
1995
+ await touchLastSeen(entry.name).catch(() => {
1996
+ });
1997
+ return {
1998
+ entry,
1999
+ graph,
2000
+ outPath,
2001
+ paths,
2002
+ stopPersist,
2003
+ stopStaleness,
2004
+ stopConnectors,
2005
+ detachEvents,
2006
+ status: "active"
2007
+ };
2008
+ } catch (err) {
2009
+ detachEvents();
2010
+ throw err;
2011
+ }
2012
+ }
2013
+ function resolveRestPort(opts) {
2014
+ if (typeof opts.restPort === "number") return opts.restPort;
2015
+ const env = process.env.PORT;
2016
+ if (env && env.length > 0) {
2017
+ const n = Number.parseInt(env, 10);
2018
+ if (Number.isFinite(n)) return n;
2019
+ }
2020
+ return 8080;
2021
+ }
2022
+ function resolveOtlpPort(opts) {
2023
+ if (typeof opts.otlpPort === "number") return opts.otlpPort;
2024
+ const env = process.env.OTEL_PORT;
2025
+ if (env && env.length > 0) {
2026
+ const n = Number.parseInt(env, 10);
2027
+ if (Number.isFinite(n)) return n;
2028
+ }
2029
+ return 4318;
2030
+ }
2031
+ function resolveWebPort() {
2032
+ const env = process.env.NEAT_WEB_PORT;
2033
+ if (env && env.length > 0) {
2034
+ const n = Number.parseInt(env, 10);
2035
+ if (Number.isFinite(n)) return n;
2036
+ }
2037
+ return 6328;
2038
+ }
2039
+ function portFromListenAddress(address, fallback) {
2040
+ try {
2041
+ const port = new URL(address).port;
2042
+ const n = Number.parseInt(port, 10);
2043
+ if (Number.isFinite(n) && n > 0) return n;
2044
+ } catch {
2045
+ }
2046
+ return fallback;
2047
+ }
2048
+ function resolveHost(opts, authTokenSet) {
2049
+ if (opts.host && opts.host.length > 0) return opts.host;
2050
+ const env = process.env.HOST;
2051
+ if (env && env.length > 0) return env;
2052
+ if (!authTokenSet) return "127.0.0.1";
2053
+ return "0.0.0.0";
2054
+ }
2055
+ async function startDaemon(opts = {}) {
2056
+ const home = neatHomeFor(opts);
2057
+ const regPath = registryPath();
2058
+ const projectArg = typeof opts.project === "string" && opts.project.length > 0 ? opts.project : process.env.NEAT_PROJECT && process.env.NEAT_PROJECT.length > 0 ? process.env.NEAT_PROJECT : null;
2059
+ const projectPathArg = opts.projectPath && opts.projectPath.length > 0 ? opts.projectPath : process.env.NEAT_PROJECT_PATH && process.env.NEAT_PROJECT_PATH.length > 0 ? process.env.NEAT_PROJECT_PATH : null;
2060
+ const singleProject = projectArg;
2061
+ const singleProjectPath = singleProject && projectPathArg ? path3.resolve(projectPathArg) : null;
2062
+ if (singleProject && !singleProjectPath) {
2063
+ throw new Error(
2064
+ `neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
2065
+ );
2066
+ }
2067
+ if (!singleProject) {
2068
+ try {
2069
+ await fs3.access(regPath);
2070
+ } catch {
2071
+ throw new Error(
2072
+ `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
2073
+ );
2074
+ }
2075
+ }
2076
+ const pidPath = path3.join(home, "neatd.pid");
2077
+ await writeAtomically(pidPath, `${process.pid}
2078
+ `);
2079
+ const slots = /* @__PURE__ */ new Map();
2080
+ const registry = new Projects();
2081
+ const bootstrapStatus = /* @__PURE__ */ new Map();
2082
+ const bootstrapStartedAt = /* @__PURE__ */ new Map();
2083
+ const DROP_WARN_INTERVAL_MS = 6e4;
2084
+ const lastDropWarnAt = /* @__PURE__ */ new Map();
2085
+ function warnDroppedSpan(project, reason) {
2086
+ const now = Date.now();
2087
+ const prev = lastDropWarnAt.get(project) ?? 0;
2088
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
2089
+ lastDropWarnAt.set(project, now);
2090
+ console.warn(
2091
+ `[neatd] dropping span for project "${project}" \u2014 project status: broken (${reason}). Run \`neatd reload\` to retry bootstrap.`
2092
+ );
2093
+ }
2094
+ const unroutedPath = unroutedErrorsPath(home);
2095
+ const lastUnroutedWarnAt = /* @__PURE__ */ new Map();
2096
+ async function recordUnroutedSpan(serviceName, traceId) {
2097
+ const key = serviceName ?? "<missing>";
2098
+ const now = Date.now();
2099
+ try {
2100
+ await appendUnroutedSpan(home, buildUnroutedSpanRecord(serviceName, traceId, new Date(now)));
2101
+ } catch {
2102
+ }
2103
+ const prev = lastUnroutedWarnAt.get(key) ?? 0;
2104
+ if (now - prev < DROP_WARN_INTERVAL_MS) return;
2105
+ lastUnroutedWarnAt.set(key, now);
2106
+ console.warn(
2107
+ `[neatd] dropping span \u2014 service.name "${key}" matches no registered project and no \`default\` project exists. See ${unroutedPath}.`
2108
+ );
2109
+ }
2110
+ function upsertRegistryFromSlot(slot) {
2111
+ if (slot.status !== "active") return;
2112
+ registry.set(slot.entry.name, {
2113
+ scanPath: slot.entry.path,
2114
+ paths: slot.paths,
2115
+ graph: slot.graph
2116
+ });
2117
+ }
2118
+ async function tryRecoverSlot(entry) {
2119
+ try {
2120
+ const fresh = await bootstrapProject(entry, opts.connectors ?? [], home);
2121
+ const prior = slots.get(entry.name);
2122
+ if (prior) teardownSlot(prior);
2123
+ slots.set(entry.name, fresh);
2124
+ upsertRegistryFromSlot(fresh);
2125
+ if (fresh.status === "active") {
2126
+ await setStatus(entry.name, "active").catch(() => {
2127
+ });
2128
+ console.log(
2129
+ `neatd: project "${entry.name}" recovered from broken \u2014 active`
2130
+ );
2131
+ }
2132
+ return fresh;
2133
+ } catch (err) {
2134
+ console.warn(
2135
+ `neatd: project "${entry.name}" still broken after recovery attempt \u2014 ${err.message}`
2136
+ );
2137
+ return slots.get(entry.name);
2138
+ }
2139
+ }
2140
+ async function bootstrapOne(entry) {
2141
+ bootstrapStatus.set(entry.name, "bootstrapping");
2142
+ bootstrapStartedAt.set(entry.name, Date.now());
2143
+ try {
2144
+ const slot = await bootstrapProject(entry, opts.connectors ?? [], home);
2145
+ const prior = slots.get(entry.name);
2146
+ if (prior) teardownSlot(prior);
2147
+ slots.set(entry.name, slot);
2148
+ upsertRegistryFromSlot(slot);
2149
+ bootstrapStatus.set(entry.name, slot.status === "broken" ? "broken" : "active");
2150
+ if (slot.status === "broken") {
2151
+ console.warn(`neatd: project "${entry.name}" broken \u2014 ${slot.errorReason}`);
2152
+ } else {
2153
+ console.log(`neatd: project "${entry.name}" active (${entry.path})`);
2154
+ }
2155
+ } catch (err) {
2156
+ bootstrapStatus.set(entry.name, "broken");
2157
+ console.warn(
2158
+ `neatd: project "${entry.name}" failed to bootstrap \u2014 ${err.message}`
2159
+ );
2160
+ await setStatus(entry.name, "broken").catch(() => {
2161
+ });
2162
+ }
2163
+ }
2164
+ async function enumerateProjects() {
2165
+ if (singleProject && singleProjectPath) {
2166
+ return [
2167
+ {
2168
+ name: singleProject,
2169
+ path: singleProjectPath,
2170
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
2171
+ languages: [],
2172
+ status: "active"
2173
+ }
2174
+ ];
2175
+ }
2176
+ return listProjects();
2177
+ }
2178
+ async function loadAll() {
2179
+ if (!singleProject) {
2180
+ try {
2181
+ const pruned = await pruneRegistry();
2182
+ for (const entry of pruned) {
2183
+ console.log(
2184
+ `neatd: pruned project "${entry.name}" \u2014 registered path ${entry.path} is gone`
2185
+ );
2186
+ slots.delete(entry.name);
2187
+ bootstrapStatus.delete(entry.name);
2188
+ bootstrapStartedAt.delete(entry.name);
2189
+ }
2190
+ } catch (err) {
2191
+ console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
2192
+ }
2193
+ }
2194
+ const projects = await enumerateProjects();
2195
+ const seen = /* @__PURE__ */ new Set();
2196
+ const pending = [];
2197
+ for (const entry of projects) {
2198
+ seen.add(entry.name);
2199
+ const existing = slots.get(entry.name);
2200
+ if (existing) {
2201
+ if (existing.status === "broken") {
2202
+ pending.push(tryRecoverSlot(entry).then(() => {
2203
+ }));
2204
+ }
2205
+ continue;
2206
+ }
2207
+ pending.push(bootstrapOne(entry));
2208
+ }
2209
+ for (const [name, slot] of [...slots.entries()]) {
2210
+ if (seen.has(name)) continue;
2211
+ teardownSlot(slot);
2212
+ slots.delete(name);
2213
+ bootstrapStatus.delete(name);
2214
+ bootstrapStartedAt.delete(name);
2215
+ console.log(`neatd: project "${name}" removed from registry \u2014 stopped`);
2216
+ }
2217
+ await Promise.allSettled(pending);
2218
+ }
2219
+ const initialEntries = await enumerateProjects().catch(() => []);
2220
+ for (const entry of initialEntries) {
2221
+ bootstrapStatus.set(entry.name, "bootstrapping");
2222
+ bootstrapStartedAt.set(entry.name, Date.now());
2223
+ }
2224
+ const bind = opts.bindListeners !== false;
2225
+ let restApp = null;
2226
+ let otlpApp = null;
2227
+ let restAddress = "";
2228
+ let otlpAddress = "";
2229
+ let daemonRecord = null;
2230
+ if (bind) {
2231
+ const auth = readAuthEnv();
2232
+ const host = resolveHost(opts, Boolean(auth.authToken));
2233
+ const restPort = resolveRestPort(opts);
2234
+ const otlpPort = resolveOtlpPort(opts);
2235
+ assertBindAuthority(host, auth.authToken);
2236
+ try {
2237
+ restApp = await buildApi({
2238
+ projects: registry,
2239
+ authToken: auth.authToken,
2240
+ trustProxy: auth.trustProxy,
2241
+ publicRead: auth.publicRead,
2242
+ bootstrap: {
2243
+ status: (name) => bootstrapStatus.get(name),
2244
+ list: () => {
2245
+ const now = Date.now();
2246
+ return [...bootstrapStatus.entries()].map(([name, status]) => ({
2247
+ name,
2248
+ status,
2249
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
2250
+ }));
2251
+ }
2252
+ },
2253
+ // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
2254
+ // surface reflects "the daemon is the project": `GET /projects` reports
2255
+ // only this project (the dashboard pins to it), and the daemon-wide
2256
+ // `/health` carries it at the top level for the spawn-reuse identity
2257
+ // check. Absent for the legacy multi-project daemon.
2258
+ singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0
2259
+ });
2260
+ restAddress = await restApp.listen({ port: restPort, host });
2261
+ console.log(
2262
+ `neatd: REST listening on http://${host}:${portFromListenAddress(restAddress, restPort)}`
2263
+ );
2264
+ } catch (err) {
2265
+ for (const slot of slots.values()) {
2266
+ teardownSlot(slot);
2267
+ }
2268
+ if (restApp) await restApp.close().catch(() => {
2269
+ });
2270
+ await fs3.unlink(pidPath).catch(() => {
2271
+ });
2272
+ throw new Error(
2273
+ `neatd: failed to bind REST on port ${restPort} \u2014 ${err.message}`
2274
+ );
2275
+ }
2276
+ async function resolveTargetSlot(serviceName, traceId) {
2277
+ if (singleProject) {
2278
+ let slot2 = slots.get(singleProject);
2279
+ if (!slot2) {
2280
+ slot2 = await tryRecoverSlot({
2281
+ name: singleProject,
2282
+ path: singleProjectPath,
2283
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
2284
+ languages: [],
2285
+ status: "active"
2286
+ });
2287
+ } else if (slot2.status === "broken") {
2288
+ slot2 = await tryRecoverSlot(slot2.entry);
2289
+ }
2290
+ if (!slot2 || slot2.status !== "active") {
2291
+ warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
2292
+ return null;
2293
+ }
2294
+ if (!spanBelongsToSingleProject(slot2.graph, singleProject, serviceName)) {
2295
+ await recordUnroutedSpan(serviceName, traceId);
2296
+ return null;
2297
+ }
2298
+ return slot2;
2299
+ }
2300
+ const liveEntries = await listProjects().catch(() => []);
2301
+ const target = routeSpanToProject(serviceName, liveEntries);
2302
+ let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
2303
+ if (!slot) {
2304
+ await recordUnroutedSpan(serviceName, traceId);
2305
+ return null;
2306
+ }
2307
+ if (slot.status === "broken") {
2308
+ const entry = liveEntries.find((e) => e.name === slot.entry.name);
2309
+ if (entry) {
2310
+ slot = await tryRecoverSlot(entry);
2311
+ }
2312
+ if (slot.status !== "active") {
2313
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
2314
+ return null;
2315
+ }
2316
+ }
2317
+ return slot.status === "active" ? slot : null;
2318
+ }
2319
+ async function resolveSlotByName(project, serviceName, traceId) {
2320
+ const liveEntries = await listProjects().catch(() => []);
2321
+ let slot = slots.get(project);
2322
+ if (!slot) {
2323
+ await recordUnroutedSpan(serviceName, traceId);
2324
+ return null;
2325
+ }
2326
+ if (slot.status === "broken") {
2327
+ const entry = liveEntries.find((e) => e.name === slot.entry.name);
2328
+ if (entry) {
2329
+ slot = await tryRecoverSlot(entry);
2330
+ }
2331
+ if (slot.status !== "active") {
2332
+ warnDroppedSpan(slot.entry.name, slot.errorReason ?? "unknown");
2333
+ return null;
2334
+ }
2335
+ }
2336
+ return slot.status === "active" ? slot : null;
2337
+ }
2338
+ try {
2339
+ otlpApp = await buildOtelReceiver({
2340
+ authToken: auth.otelToken,
2341
+ trustProxy: auth.trustProxy,
2342
+ onSpan: async (span) => {
2343
+ const slot = await resolveTargetSlot(span.service, span.traceId);
2344
+ if (!slot) return;
2345
+ await handleSpan(
2346
+ {
2347
+ graph: slot.graph,
2348
+ errorsPath: slot.paths.errorsPath,
2349
+ scanPath: slot.entry.path,
2350
+ project: slot.entry.name,
2351
+ // Receiver already wrote the error event synchronously below.
2352
+ writeErrorEventInline: false
2353
+ },
2354
+ span
2355
+ );
2356
+ },
2357
+ onErrorSpanSync: async (span) => {
2358
+ const slot = await resolveTargetSlot(span.service, span.traceId);
2359
+ if (!slot) return;
2360
+ await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
2361
+ },
2362
+ // Project-scoped route (issue #367) — the URL already named the
2363
+ // project. Resolution is a direct slot lookup; service.name resolves
2364
+ // the ServiceNode inside the slot's graph instead of which project
2365
+ // owns the span.
2366
+ onProjectSpan: async (project, span) => {
2367
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
2368
+ if (!slot) return;
2369
+ await handleSpan(
2370
+ {
2371
+ graph: slot.graph,
2372
+ errorsPath: slot.paths.errorsPath,
2373
+ scanPath: slot.entry.path,
2374
+ project: slot.entry.name,
2375
+ writeErrorEventInline: false
2376
+ },
2377
+ span
2378
+ );
2379
+ },
2380
+ onProjectErrorSpanSync: async (project, span) => {
2381
+ const slot = await resolveSlotByName(project, span.service, span.traceId);
2382
+ if (!slot) return;
2383
+ await makeErrorSpanWriter(slot.paths.errorsPath, slot.graph, slot.entry.path)(span);
2384
+ }
2385
+ });
2386
+ otlpAddress = await listenSteppingOtlp(otlpApp, otlpPort, host);
2387
+ console.log(`neatd: OTLP listening on ${otlpAddress}/v1/traces`);
2388
+ } catch (err) {
2389
+ for (const slot of slots.values()) {
2390
+ teardownSlot(slot);
2391
+ }
2392
+ if (restApp) await restApp.close().catch(() => {
2393
+ });
2394
+ if (otlpApp) await otlpApp.close().catch(() => {
2395
+ });
2396
+ await fs3.unlink(pidPath).catch(() => {
2397
+ });
2398
+ throw new Error(
2399
+ `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
2400
+ );
2401
+ }
2402
+ if (singleProject && singleProjectPath) {
2403
+ const ports = {
2404
+ rest: portFromListenAddress(restAddress, restPort),
2405
+ otlp: portFromListenAddress(otlpAddress, otlpPort),
2406
+ // The daemon doesn't bind the web port itself (neatd spawns the web
2407
+ // child); it records the allocated value passed through so the
2408
+ // dashboard and `neat ps` agree on where to look.
2409
+ web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
2410
+ };
2411
+ daemonRecord = {
2412
+ project: singleProject,
2413
+ projectPath: singleProjectPath,
2414
+ pid: process.pid,
2415
+ status: "running",
2416
+ ports,
2417
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2418
+ neatVersion: resolveNeatVersion()
2419
+ };
2420
+ try {
2421
+ await writeDaemonRecord(daemonRecord, home);
2422
+ console.log(
2423
+ `neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
2424
+ );
2425
+ } catch (err) {
2426
+ for (const slot of slots.values()) teardownSlot(slot);
2427
+ if (restApp) await restApp.close().catch(() => {
2428
+ });
2429
+ if (otlpApp) await otlpApp.close().catch(() => {
2430
+ });
2431
+ await fs3.unlink(pidPath).catch(() => {
2432
+ });
2433
+ throw new Error(
2434
+ `neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
2435
+ );
2436
+ }
2437
+ }
2438
+ }
2439
+ const initialBootstrap = loadAll().catch((err) => {
2440
+ console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
2441
+ });
2442
+ let reloading = initialBootstrap;
2443
+ const reload = async () => {
2444
+ if (reloading) return reloading;
2445
+ reloading = (async () => {
2446
+ try {
2447
+ await loadAll();
2448
+ } finally {
2449
+ reloading = null;
2450
+ }
2451
+ })();
2452
+ return reloading;
2453
+ };
2454
+ void initialBootstrap.finally(() => {
2455
+ if (reloading === initialBootstrap) reloading = null;
2456
+ });
2457
+ const tracker = {
2458
+ status: (name) => bootstrapStatus.get(name),
2459
+ list: () => {
2460
+ const now = Date.now();
2461
+ return [...bootstrapStatus.entries()].map(([name, status]) => ({
2462
+ name,
2463
+ status,
2464
+ elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
2465
+ }));
2466
+ }
2467
+ };
2468
+ const sighupHandler = () => {
2469
+ void reload().catch((err) => {
2470
+ console.warn(`neatd: SIGHUP reload failed \u2014 ${err.message}`);
2471
+ });
2472
+ };
2473
+ process.on("SIGHUP", sighupHandler);
2474
+ const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
2475
+ let registryWatcher = null;
2476
+ let reloadTimer = null;
2477
+ if (!singleProject) try {
2478
+ const regDir = path3.dirname(regPath);
2479
+ const regBase = path3.basename(regPath);
2480
+ registryWatcher = watch(regDir, (_eventType, filename) => {
2481
+ if (filename !== null && filename !== regBase) return;
2482
+ if (reloadTimer) clearTimeout(reloadTimer);
2483
+ reloadTimer = setTimeout(() => {
2484
+ reloadTimer = null;
2485
+ void reload().catch((err) => {
2486
+ console.warn(
2487
+ `neatd: registry-watch reload failed \u2014 ${err.message}`
2488
+ );
2489
+ });
2490
+ }, REGISTRY_RELOAD_DEBOUNCE_MS);
2491
+ });
2492
+ } catch (err) {
2493
+ console.warn(
2494
+ `neatd: failed to watch registry at ${regPath} \u2014 ${err.message}. Run \`neatd reload\` (or send SIGHUP) after registering new projects.`
2495
+ );
2496
+ }
2497
+ let stopped = false;
2498
+ const stop = async () => {
2499
+ if (stopped) return;
2500
+ stopped = true;
2501
+ process.off("SIGHUP", sighupHandler);
2502
+ if (reloadTimer) {
2503
+ clearTimeout(reloadTimer);
2504
+ reloadTimer = null;
2505
+ }
2506
+ if (registryWatcher) {
2507
+ try {
2508
+ registryWatcher.close();
2509
+ } catch {
2510
+ }
2511
+ registryWatcher = null;
2512
+ }
2513
+ if (otlpApp) await otlpApp.close().catch(() => {
2514
+ });
2515
+ if (restApp) await restApp.close().catch(() => {
2516
+ });
2517
+ for (const slot of slots.values()) {
2518
+ if (slot.status === "active" && slot.outPath) {
2519
+ await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
2520
+ });
2521
+ }
2522
+ }
2523
+ for (const slot of slots.values()) {
2524
+ teardownSlot(slot);
2525
+ }
2526
+ if (daemonRecord) {
2527
+ await clearDaemonRecord(daemonRecord, home);
2528
+ }
2529
+ await fs3.unlink(pidPath).catch(() => {
2530
+ });
2531
+ };
2532
+ return {
2533
+ slots,
2534
+ reload,
2535
+ stop,
2536
+ pidPath,
2537
+ restAddress,
2538
+ otlpAddress,
2539
+ bootstrap: tracker,
2540
+ initialBootstrap,
2541
+ daemonRecord,
2542
+ neatHome: home
2543
+ };
2544
+ }
2545
+
2546
+ export {
2547
+ readConnectorsConfig,
2548
+ isEnvRef,
2549
+ upsertConnectorEntry,
2550
+ removeConnectorEntry,
2551
+ autoSlugConnectorId,
2552
+ describeCredential,
2553
+ PROVIDER_DISPATCH,
2554
+ getProviderDispatch,
2555
+ validateConnectorEntry,
2556
+ readDaemonRecord,
2557
+ resolveNeatVersion,
2558
+ writeDaemonRecord,
2559
+ clearDaemonRecord,
2560
+ reconcileDaemonRecordSync,
2561
+ routeSpanToProject,
2562
+ portFromListenAddress,
2563
+ resolveHost,
2564
+ startDaemon
2565
+ };
2566
+ //# sourceMappingURL=chunk-NY5Q6NE2.js.map