@ryanstark24/sfgraph-cli 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/commands/ingest.d.ts +73 -0
  2. package/dist/commands/ingest.d.ts.map +1 -1
  3. package/dist/commands/ingest.js +334 -20
  4. package/dist/commands/ingest.js.map +1 -1
  5. package/dist/commands/serve.d.ts +7 -0
  6. package/dist/commands/serve.d.ts.map +1 -1
  7. package/dist/commands/serve.js +59 -1
  8. package/dist/commands/serve.js.map +1 -1
  9. package/dist/commands/skip-labels.d.ts +11 -0
  10. package/dist/commands/skip-labels.d.ts.map +1 -0
  11. package/dist/commands/skip-labels.js +15 -0
  12. package/dist/commands/skip-labels.js.map +1 -0
  13. package/dist/commands/telemetry.js +1 -1
  14. package/dist/commands/telemetry.js.map +1 -1
  15. package/dist/heap.d.ts +24 -0
  16. package/dist/heap.d.ts.map +1 -0
  17. package/dist/heap.js +45 -0
  18. package/dist/heap.js.map +1 -0
  19. package/dist/index.d.ts +13 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -2
  22. package/dist/index.js.map +1 -1
  23. package/package.json +8 -7
  24. package/dist/__tests__/diagnose.test.d.ts +0 -2
  25. package/dist/__tests__/diagnose.test.d.ts.map +0 -1
  26. package/dist/__tests__/diagnose.test.js +0 -272
  27. package/dist/__tests__/diagnose.test.js.map +0 -1
  28. package/dist/__tests__/doctor.test.d.ts +0 -2
  29. package/dist/__tests__/doctor.test.d.ts.map +0 -1
  30. package/dist/__tests__/doctor.test.js +0 -64
  31. package/dist/__tests__/doctor.test.js.map +0 -1
  32. package/dist/__tests__/ingest-db-guard.test.d.ts +0 -2
  33. package/dist/__tests__/ingest-db-guard.test.d.ts.map +0 -1
  34. package/dist/__tests__/ingest-db-guard.test.js +0 -110
  35. package/dist/__tests__/ingest-db-guard.test.js.map +0 -1
  36. package/dist/__tests__/ingest-multi.test.d.ts +0 -2
  37. package/dist/__tests__/ingest-multi.test.d.ts.map +0 -1
  38. package/dist/__tests__/ingest-multi.test.js +0 -51
  39. package/dist/__tests__/ingest-multi.test.js.map +0 -1
  40. package/dist/__tests__/install-dry-run.test.d.ts +0 -2
  41. package/dist/__tests__/install-dry-run.test.d.ts.map +0 -1
  42. package/dist/__tests__/install-dry-run.test.js +0 -29
  43. package/dist/__tests__/install-dry-run.test.js.map +0 -1
  44. package/dist/__tests__/install-e2e.test.d.ts +0 -2
  45. package/dist/__tests__/install-e2e.test.d.ts.map +0 -1
  46. package/dist/__tests__/install-e2e.test.js +0 -33
  47. package/dist/__tests__/install-e2e.test.js.map +0 -1
  48. package/dist/__tests__/mcp-config.test.d.ts +0 -2
  49. package/dist/__tests__/mcp-config.test.d.ts.map +0 -1
  50. package/dist/__tests__/mcp-config.test.js +0 -104
  51. package/dist/__tests__/mcp-config.test.js.map +0 -1
  52. package/dist/__tests__/rebuild.test.d.ts +0 -2
  53. package/dist/__tests__/rebuild.test.d.ts.map +0 -1
  54. package/dist/__tests__/rebuild.test.js +0 -180
  55. package/dist/__tests__/rebuild.test.js.map +0 -1
  56. package/dist/__tests__/serve-guard.test.d.ts +0 -2
  57. package/dist/__tests__/serve-guard.test.d.ts.map +0 -1
  58. package/dist/__tests__/serve-guard.test.js +0 -82
  59. package/dist/__tests__/serve-guard.test.js.map +0 -1
  60. package/dist/__tests__/snapshot.test.d.ts +0 -2
  61. package/dist/__tests__/snapshot.test.d.ts.map +0 -1
  62. package/dist/__tests__/snapshot.test.js +0 -139
  63. package/dist/__tests__/snapshot.test.js.map +0 -1
  64. package/dist/__tests__/telemetry.test.d.ts +0 -2
  65. package/dist/__tests__/telemetry.test.d.ts.map +0 -1
  66. package/dist/__tests__/telemetry.test.js +0 -53
  67. package/dist/__tests__/telemetry.test.js.map +0 -1
@@ -1,4 +1,10 @@
1
+ import type { LiveIngestOpts } from "@ryanstark24/sfgraph-core";
2
+ import { ConsoleLogger } from "@ryanstark24/sfgraph-shared";
1
3
  export interface IngestOpts {
4
+ /** Offline ingest of an SFDX checkout; --org then only sets the alias. */
5
+ project?: string | undefined;
6
+ /** With --project: re-ingest only the members that changed (Task 11c). */
7
+ delta?: boolean | undefined;
2
8
  org?: string | undefined;
3
9
  orgs?: string | undefined;
4
10
  all?: boolean | undefined;
@@ -10,6 +16,9 @@ export interface IngestOpts {
10
16
  embedModelDim?: number | undefined;
11
17
  rebuild?: boolean | undefined;
12
18
  noBackup?: boolean | undefined;
19
+ /** Return at "graph ready" instead of waiting for embeddings
20
+ * (`--no-wait-embeddings`); the next ingest fills the pending vectors. */
21
+ noWaitEmbeddings?: boolean | undefined;
13
22
  detectDeletions?: boolean | undefined;
14
23
  /** Comma-separated source labels to fetch (e.g. 'apex,generic:Profile').
15
24
  * Forces mode=full and merges into the existing graph without rebuild. */
@@ -44,5 +53,69 @@ export interface IngestOpts {
44
53
  * Env: SFGRAPH_SKIP_THRESHOLD. Default 5. */
45
54
  skipThreshold?: number | undefined;
46
55
  }
56
+ /**
57
+ * The persistent embedding cache for the active model:
58
+ * `<dataDir>/embedding-cache/<modelId>@<sha16>[@<cap>].sqlite`, where sha16 is
59
+ * the first 16 hex chars of the model weights file's sha256, so new weights
60
+ * under the same model id never reuse old vectors. A non-default token cap
61
+ * changes the vectors too, so it gets its own file. Undefined (no cache) when
62
+ * the model file cannot be found: there is nothing trustworthy to key it by.
63
+ * The file lives outside the per-org graph DB, so `--rebuild` reuses it; it
64
+ * only grows.
65
+ */
66
+ export declare function embeddingCacheFileFor(dataDir: string): Promise<string | undefined>;
67
+ /**
68
+ * Ruling (a): an embedding failure after graph ready is not an ingest failure.
69
+ * The graph is complete and the unembedded texts stay flagged pending, so the
70
+ * next ingest fills them (find_similar uses text search meanwhile). Warn and
71
+ * leave the exit code alone; live-ingest already printed the cause.
72
+ */
73
+ export declare function reportEmbeddingOutcome(result: {
74
+ embeddings?: {
75
+ error?: string;
76
+ failed?: number;
77
+ pending?: number;
78
+ };
79
+ }): void;
80
+ /** Give every built ingest the same embedder (multi-org runs: one worker and
81
+ * one model in memory instead of one per org). The caller closes it. */
82
+ export declare function shareEmbedder(build: (alias: string) => Promise<LiveIngestOpts>, embedder: NonNullable<LiveIngestOpts["embedder"]>): (alias: string) => Promise<LiveIngestOpts>;
83
+ /** Offline ingest of an SFDX checkout (plus vlocity/datapacks when present).
84
+ * Reuses buildSingleIngestOpts with a resolver that never contacts an org. */
85
+ export declare function buildProjectIngestOpts(opts: IngestOpts, logger: ConsoleLogger, deps?: {
86
+ onResolved?: (orgId: string, alias: string) => void;
87
+ }): Promise<LiveIngestOpts>;
88
+ /**
89
+ * Run a move/delete of the graph file. On Windows a file another process has
90
+ * open (`sfgraph serve`, or an MCP client's server) cannot be renamed or
91
+ * deleted: report that plainly instead of a raw EBUSY/EPERM. Nothing has been
92
+ * moved when this throws, so the existing graph is untouched.
93
+ */
94
+ export declare function onGraphFile<T>(dbPath: string, op: () => T): T;
95
+ /** Rebuild backups kept per org (each is a full copy of the graph file). */
96
+ export declare const REBUILD_BACKUPS_KEPT = 2;
97
+ /**
98
+ * Delete all but the newest {@link REBUILD_BACKUPS_KEPT} rebuild backups of
99
+ * one org (with their sidecars). The names carry an ISO stamp, so name order
100
+ * is time order. The newest backup that still holds snapshot history is always
101
+ * kept too: a rebuild whose snapshot copy failed leaves a backup without
102
+ * history, and two of those in a row must not push the real history out.
103
+ * Best-effort.
104
+ */
105
+ export declare function pruneRebuildBackups(backupDir: string, orgId: string): Promise<string[]>;
106
+ /**
107
+ * Task 11c: one ingest per org at a time (`<dataDir>/<orgId>.ingest.lock`).
108
+ * Taken as soon as an org is resolved, before --rebuild moves its graph;
109
+ * released when the command ends. Re-taking a held org is a no-op.
110
+ * While a lock is held, SIGINT / SIGTERM release it and exit with 128 + signo
111
+ * (a stale lock is only detectable on the same host).
112
+ */
113
+ export declare function ingestLocks(o?: {
114
+ dir?: string;
115
+ onSignal?: (sig: NodeJS.Signals) => void;
116
+ }): {
117
+ take: (orgId: string, alias: string) => void;
118
+ releaseAll: () => void;
119
+ };
47
120
  export declare function ingestCmd(opts: IngestOpts): Promise<void>;
48
121
  //# sourceMappingURL=ingest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,GAAG,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,GAAG,MAAM,GAAG,SAAS,CAAC;IACnD,EAAE,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC/B,eAAe,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACtC;+EAC2E;IAC3E,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B;;oBAEgB;IAChB,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACnC,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC;;uDAEmD;IACnD,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,oEAAoE;IACpE,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B;;;;iEAI6D;IAC7D,WAAW,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAClC,oEAAoE;IACpE,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,sDAAsD;IACtD,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACrC,+DAA+D;IAC/D,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACpC,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9B;kDAC8C;IAC9C,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAoOD,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CA2L/D"}
1
+ {"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,9 @@
1
+ import { existsSync } from "node:fs";
2
+ import { constants as osConstants } from "node:os";
1
3
  import path from "node:path";
2
- import { SqliteGraphStore, SqliteSnapshotStore, SqliteVectorStore, listAllAuthenticatedOrgs, liveIngest, multiOrgIngest, } from "@ryanstark24/sfgraph-core";
4
+ import { EmbeddingPool, SqliteGraphStore, SqliteSnapshotStore, SqliteVectorStore, acquireIngestLock, defaultEmbeddingWorkerUrl, embedMaxTokens, embedModelFingerprint, embeddingCachePath, listAllAuthenticatedOrgs, liveIngest, multiOrgIngest, resolveEmbedModelId, snapshotCountInFile, } from "@ryanstark24/sfgraph-core";
3
5
  import { ConsoleLogger, SfgraphError, getSfgraphPaths, safeOrgDbPath, } from "@ryanstark24/sfgraph-shared";
6
+ import { retrySkippedLabels } from "./skip-labels.js";
4
7
  /** Skip categories that are worth retrying. `insufficient_access` and
5
8
  * `not_found` are stable conditions that won't fix themselves on a
6
9
  * second pass; retrying them just wastes time. */
@@ -44,21 +47,46 @@ function validateDbPath(rawDbPath, dataDir) {
44
47
  }
45
48
  async function buildSingleIngestOpts(alias, opts, logger, deps) {
46
49
  const resolved = await deps.resolveOrg(alias);
50
+ deps.onResolved?.(String(resolved.orgId), alias);
47
51
  const dataDir = getSfgraphPaths().data;
48
52
  const dbPath = opts.db
49
53
  ? validateDbPath(opts.db, dataDir)
50
54
  : safeOrgDbPath(dataDir, String(resolved.orgId));
51
- if (opts.rebuild) {
52
- await applyRebuild(dbPath, String(resolved.orgId), Boolean(opts.noBackup), logger);
53
- }
55
+ const backupPath = opts.rebuild
56
+ ? await applyRebuild(dbPath, String(resolved.orgId), Boolean(opts.noBackup), logger)
57
+ : undefined;
54
58
  const graphStore = new SqliteGraphStore({ dbPath });
55
59
  await graphStore.init();
60
+ // Closed if anything below fails: the caller only closes what it gets back.
61
+ try {
62
+ return await buildOnStore(graphStore, {
63
+ alias,
64
+ opts,
65
+ logger,
66
+ dbPath,
67
+ dataDir,
68
+ resolved,
69
+ backupPath,
70
+ });
71
+ }
72
+ catch (e) {
73
+ await graphStore.close().catch(() => { });
74
+ throw e;
75
+ }
76
+ }
77
+ async function buildOnStore(graphStore, c) {
78
+ const { alias, opts, logger, dbPath, dataDir, resolved, backupPath } = c;
56
79
  const snapshotStore = new SqliteSnapshotStore({
57
80
  dbPath,
58
81
  db: graphStore.db,
59
82
  skipMigrations: true,
60
83
  });
61
84
  await snapshotStore.init();
85
+ // A rebuild keeps the snapshot history (point_in_time_diff): copy it from
86
+ // the backup before the first snapshot of the new graph is taken.
87
+ const carried = backupPath
88
+ ? carrySnapshots(snapshotStore, backupPath, String(resolved.orgId))
89
+ : 0;
62
90
  // Wire a vector store so ingest GENERATES MiniLM embeddings — without this
63
91
  // `opts.vectorStore` is undefined, live-ingest never builds its embedding
64
92
  // queue, and the vec0 tables stay empty (semantic find_similar returns
@@ -102,9 +130,9 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
102
130
  }
103
131
  try {
104
132
  const raw = JSON.parse(readFileSync(skipReportPath, "utf8"));
105
- const labels = (raw.skips ?? []).map((s) => s.label).filter(Boolean);
133
+ const labels = retrySkippedLabels(raw.skips ?? []);
106
134
  if (labels.length === 0) {
107
- console.log("--retry-skipped: previous run had zero skips. Nothing to retry.");
135
+ console.log("--retry-skipped: previous run had no skips. Nothing to retry.");
108
136
  // Return a no-op opts; the live-ingest will exit cleanly with no work.
109
137
  onlyLabels = new Set();
110
138
  }
@@ -117,21 +145,28 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
117
145
  throw new Error(`--retry-skipped: failed to read skip report at ${skipReportPath}: ${e.message}`);
118
146
  }
119
147
  }
148
+ const embeddingCacheFile = vectorStore ? await embeddingCacheFileFor(dataDir) : undefined;
120
149
  return {
121
150
  alias,
122
151
  mode,
123
152
  graphStore,
124
153
  snapshotStore,
125
- ...(vectorStore ? { vectorStore } : {}),
154
+ ...(vectorStore ? { vectorStore, waitEmbeddings: !opts.noWaitEmbeddings } : {}),
155
+ ...(embeddingCacheFile ? { embeddingCacheFile } : {}),
126
156
  logger,
127
157
  preResolved: resolved,
128
158
  detectDeletions: Boolean(opts.detectDeletions),
129
- ...(onlyLabels ? { onlyLabels } : {}),
159
+ // User-chosen labels (--only / --retry-skipped) are read in full: no
160
+ // early stop on identical refusals (review N1c).
161
+ ...(onlyLabels ? { onlyLabels, explicitOnlyLabels: true } : {}),
130
162
  skipReportPath,
131
163
  disableCrossFlavor: Boolean(opts.noCrossFlavor),
132
164
  disableArityResolve: Boolean(opts.noArityResolve),
133
165
  disableFlowInvocableResolve: Boolean(opts.noFlowResolve),
134
166
  disableAudit: Boolean(opts.noAudit),
167
+ // A snapshot of the still-empty rebuilt graph adds nothing to the carried
168
+ // history; the next ingest's snapshot records the rebuilt state.
169
+ ...(carried > 0 ? { skipSnapshot: true } : {}),
135
170
  // Populate the _sfgraph_findings / dead-code / governor / test-coverage
136
171
  // tables after merge. Without this the analysis MCP tools (dead_code_audit,
137
172
  // security_audit, governor_risk_check, export_sarif) fall back to weak
@@ -140,6 +175,70 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
140
175
  analysisDb: graphStore.db,
141
176
  };
142
177
  }
178
+ /**
179
+ * The persistent embedding cache for the active model:
180
+ * `<dataDir>/embedding-cache/<modelId>@<sha16>[@<cap>].sqlite`, where sha16 is
181
+ * the first 16 hex chars of the model weights file's sha256, so new weights
182
+ * under the same model id never reuse old vectors. A non-default token cap
183
+ * changes the vectors too, so it gets its own file. Undefined (no cache) when
184
+ * the model file cannot be found: there is nothing trustworthy to key it by.
185
+ * The file lives outside the per-org graph DB, so `--rebuild` reuses it; it
186
+ * only grows.
187
+ */
188
+ export async function embeddingCacheFileFor(dataDir) {
189
+ const sha = await embedModelFingerprint();
190
+ if (!sha)
191
+ return undefined;
192
+ const cap = embedMaxTokens();
193
+ const key = `${resolveEmbedModelId()}@${sha.slice(0, 16)}`;
194
+ return embeddingCachePath(dataDir, cap === 256 ? key : `${key}@${cap}`);
195
+ }
196
+ /**
197
+ * Ruling (a): an embedding failure after graph ready is not an ingest failure.
198
+ * The graph is complete and the unembedded texts stay flagged pending, so the
199
+ * next ingest fills them (find_similar uses text search meanwhile). Warn and
200
+ * leave the exit code alone; live-ingest already printed the cause.
201
+ */
202
+ export function reportEmbeddingOutcome(result) {
203
+ const e = result.embeddings;
204
+ if (!e?.error)
205
+ return;
206
+ const left = Math.max(e.pending ?? 0, e.failed ?? 0);
207
+ console.warn(`warning: the graph is complete, but ${left} texts were left unembedded (${e.error}); their vectors stay pending and the next ingest fills them`);
208
+ }
209
+ /** Give every built ingest the same embedder (multi-org runs: one worker and
210
+ * one model in memory instead of one per org). The caller closes it. */
211
+ export function shareEmbedder(build, embedder) {
212
+ return async (alias) => {
213
+ const o = await build(alias);
214
+ return o.vectorStore ? { ...o, embedder } : o;
215
+ };
216
+ }
217
+ /** Offline ingest of an SFDX checkout (plus vlocity/datapacks when present).
218
+ * Reuses buildSingleIngestOpts with a resolver that never contacts an org. */
219
+ export async function buildProjectIngestOpts(opts, logger, deps = {}) {
220
+ const root = path.resolve(String(opts.project));
221
+ if (!existsSync(path.join(root, "sfdx-project.json"))) {
222
+ throw new Error(`--project: ${root} has no sfdx-project.json`);
223
+ }
224
+ const { offlineResolvedOrg, FilesystemMetadataSource } = await import("@ryanstark24/sfgraph-core");
225
+ const alias = opts.org ?? path.basename(root);
226
+ const built = await buildSingleIngestOpts(alias, opts, logger, {
227
+ resolveOrg: (async () => offlineResolvedOrg(root, alias)),
228
+ ...(deps.onResolved ? { onResolved: deps.onResolved } : {}),
229
+ });
230
+ try {
231
+ return {
232
+ ...built,
233
+ mode: "full",
234
+ metadataSource: await FilesystemMetadataSource.fromProjectRoot(root),
235
+ };
236
+ }
237
+ catch (e) {
238
+ await built.graphStore.close().catch(() => { });
239
+ throw e;
240
+ }
241
+ }
143
242
  /**
144
243
  * SQLite uses WAL by default in this codebase, which means alongside
145
244
  * `<file>.sqlite` you also get `<file>.sqlite-wal` and `<file>.sqlite-shm`.
@@ -148,14 +247,91 @@ async function buildSingleIngestOpts(alias, opts, logger, deps) {
148
247
  * SQLite later reads back into the new DB, corrupting state.
149
248
  */
150
249
  const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"];
250
+ /**
251
+ * Run a move/delete of the graph file. On Windows a file another process has
252
+ * open (`sfgraph serve`, or an MCP client's server) cannot be renamed or
253
+ * deleted: report that plainly instead of a raw EBUSY/EPERM. Nothing has been
254
+ * moved when this throws, so the existing graph is untouched.
255
+ */
256
+ export function onGraphFile(dbPath, op) {
257
+ try {
258
+ return op();
259
+ }
260
+ catch (e) {
261
+ const code = e.code;
262
+ if (code === "EBUSY" || code === "EPERM" || code === "EACCES") {
263
+ const hint = "Stop `sfgraph serve` and any MCP client using sfgraph (Claude Code, Cursor, ...), then re-run with --rebuild.";
264
+ throw new Error(`rebuild: the graph file is in use by another process (${code}): ${dbPath}. ${hint}`, { cause: e });
265
+ }
266
+ throw e;
267
+ }
268
+ }
269
+ /** Rebuild backups kept per org (each is a full copy of the graph file). */
270
+ export const REBUILD_BACKUPS_KEPT = 2;
271
+ /**
272
+ * Copy the backup's snapshot tables into the rebuilt graph. A failure leaves
273
+ * the new graph without history (the backup still has it) and warns.
274
+ */
275
+ function carrySnapshots(store, backupPath, orgId) {
276
+ try {
277
+ const r = store.copySnapshotsFrom(backupPath);
278
+ if (r.skipped) {
279
+ console.warn(`REBUILD: snapshot history for ${orgId} not kept: ${r.skipped}`);
280
+ return 0;
281
+ }
282
+ if (r.copied > 0) {
283
+ console.warn(`REBUILD: kept ${r.copied} snapshot${r.copied === 1 ? "" : "s"} for ${orgId}`);
284
+ }
285
+ return r.copied;
286
+ }
287
+ catch (e) {
288
+ console.warn(`REBUILD: snapshot history for ${orgId} not kept (${e.message}); it is still in ${backupPath}`);
289
+ return 0;
290
+ }
291
+ }
292
+ /**
293
+ * Delete all but the newest {@link REBUILD_BACKUPS_KEPT} rebuild backups of
294
+ * one org (with their sidecars). The names carry an ISO stamp, so name order
295
+ * is time order. The newest backup that still holds snapshot history is always
296
+ * kept too: a rebuild whose snapshot copy failed leaves a backup without
297
+ * history, and two of those in a row must not push the real history out.
298
+ * Best-effort.
299
+ */
300
+ export async function pruneRebuildBackups(backupDir, orgId) {
301
+ const { readdirSync, unlinkSync } = await import("node:fs");
302
+ const prefix = `${orgId}.rebuild-`;
303
+ let names;
304
+ try {
305
+ names = readdirSync(backupDir).filter((f) => f.startsWith(prefix) && f.endsWith(".sqlite"));
306
+ }
307
+ catch {
308
+ return [];
309
+ }
310
+ const newestFirst = names.sort().reverse();
311
+ // -1 (unreadable) counts as holding history.
312
+ const history = newestFirst.find((f) => snapshotCountInFile(path.join(backupDir, f)) !== 0);
313
+ const old = newestFirst.slice(REBUILD_BACKUPS_KEPT).filter((f) => f !== history);
314
+ for (const f of old) {
315
+ for (const suffix of ["", ...SQLITE_SIDECAR_SUFFIXES]) {
316
+ try {
317
+ unlinkSync(path.join(backupDir, `${f}${suffix}`));
318
+ }
319
+ catch {
320
+ /* best-effort */
321
+ }
322
+ }
323
+ }
324
+ return old;
325
+ }
326
+ /** Move (or delete) the org's graph file; returns the backup's path when moved. */
151
327
  async function applyRebuild(dbPath, orgId, noBackup, logger) {
152
328
  const { existsSync, mkdirSync, renameSync, unlinkSync } = await import("node:fs");
153
329
  if (!existsSync(dbPath)) {
154
330
  logger.info(`rebuild: no existing graph at ${dbPath}; starting fresh`);
155
- return;
331
+ return undefined;
156
332
  }
157
333
  if (noBackup) {
158
- unlinkSync(dbPath);
334
+ onGraphFile(dbPath, () => unlinkSync(dbPath));
159
335
  for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
160
336
  const sidecar = `${dbPath}${suffix}`;
161
337
  if (existsSync(sidecar)) {
@@ -168,13 +344,13 @@ async function applyRebuild(dbPath, orgId, noBackup, logger) {
168
344
  }
169
345
  }
170
346
  console.warn(`REBUILD: existing graph for ${orgId} DELETED (no-backup); running full sync`);
171
- return;
347
+ return undefined;
172
348
  }
173
349
  const backupDir = path.join(getSfgraphPaths().data, "backups");
174
350
  mkdirSync(backupDir, { recursive: true });
175
351
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
176
352
  const backupPath = path.join(backupDir, `${orgId}.rebuild-${stamp}.sqlite`);
177
- renameSync(dbPath, backupPath);
353
+ onGraphFile(dbPath, () => renameSync(dbPath, backupPath));
178
354
  for (const suffix of SQLITE_SIDECAR_SUFFIXES) {
179
355
  const sidecar = `${dbPath}${suffix}`;
180
356
  if (existsSync(sidecar)) {
@@ -194,9 +370,54 @@ async function applyRebuild(dbPath, orgId, noBackup, logger) {
194
370
  }
195
371
  }
196
372
  console.warn(`REBUILD: existing graph for ${orgId} moved to ${backupPath}; running full sync`);
373
+ const pruned = await pruneRebuildBackups(backupDir, orgId);
374
+ if (pruned.length > 0) {
375
+ console.warn(`REBUILD: removed ${pruned.length} older backup${pruned.length === 1 ? "" : "s"} for ${orgId} (keeping the newest ${REBUILD_BACKUPS_KEPT})`);
376
+ }
377
+ return backupPath;
378
+ }
379
+ /**
380
+ * Task 11c: one ingest per org at a time (`<dataDir>/<orgId>.ingest.lock`).
381
+ * Taken as soon as an org is resolved, before --rebuild moves its graph;
382
+ * released when the command ends. Re-taking a held org is a no-op.
383
+ * While a lock is held, SIGINT / SIGTERM release it and exit with 128 + signo
384
+ * (a stale lock is only detectable on the same host).
385
+ */
386
+ export function ingestLocks(o = {}) {
387
+ const held = new Map();
388
+ const signals = ["SIGINT", "SIGTERM"];
389
+ const releaseAll = () => {
390
+ for (const l of held.values())
391
+ l.release();
392
+ held.clear();
393
+ for (const s of signals)
394
+ process.off(s, onSig);
395
+ };
396
+ const onSig = (sig) => {
397
+ releaseAll();
398
+ if (o.onSignal)
399
+ o.onSignal(sig);
400
+ // Exit explicitly: re-raising relies on the default action, which other
401
+ // SIGINT / SIGTERM listeners would suppress (the run would go on unlocked).
402
+ else
403
+ process.exit(128 + (osConstants.signals[sig] ?? 0));
404
+ };
405
+ return {
406
+ take(orgId, alias) {
407
+ if (held.has(orgId))
408
+ return;
409
+ const dir = o.dir ?? getSfgraphPaths().data;
410
+ held.set(orgId, acquireIngestLock({ dir, orgId, orgLabel: alias }));
411
+ if (held.size === 1)
412
+ for (const s of signals)
413
+ process.on(s, onSig);
414
+ },
415
+ releaseAll,
416
+ };
197
417
  }
198
418
  export async function ingestCmd(opts) {
199
419
  const logger = new ConsoleLogger("info");
420
+ const locks = ingestLocks();
200
421
  if (opts.embedModel)
201
422
  process.env.SFGRAPH_EMBED_MODEL_PATH = opts.embedModel;
202
423
  if (opts.embedModelId)
@@ -217,6 +438,33 @@ export async function ingestCmd(opts) {
217
438
  poolOverrides.data = opts.dataPool;
218
439
  const applied = await configureDefaultPools(poolOverrides);
219
440
  logger.info(`ingest: pool concurrency tooling=${applied.tooling} metadata=${applied.metadata} data=${applied.data}`);
441
+ if (opts.delta && !opts.project)
442
+ throw new Error("--delta needs --project");
443
+ // An offline ingest reads every member of the checkout; a label filter has
444
+ // nothing to narrow (final review core M-3).
445
+ if (opts.project && (opts.only || opts.retrySkipped)) {
446
+ throw new Error(`${opts.only ? "--only" : "--retry-skipped"} does not work with --project: a project ingest always reads the whole checkout`);
447
+ }
448
+ if (opts.delta && opts.rebuild)
449
+ throw new Error("--delta and --rebuild are mutually exclusive");
450
+ if (opts.project && opts.delta) {
451
+ await projectDelta(opts, logger, locks.take);
452
+ return;
453
+ }
454
+ if (opts.project) {
455
+ const built = await buildProjectIngestOpts(opts, logger, { onResolved: locks.take });
456
+ const result = await liveIngest(built);
457
+ reportEmbeddingOutcome(result);
458
+ console.log(`ingest --project: ${result.membersProcessed} members, ${result.parseErrors} parse errors -> ${built.preResolved?.orgId}`);
459
+ const dp = built.metadataSource?.dataPackReport;
460
+ if (dp) {
461
+ console.log(`datapacks: ${JSON.stringify(dp.types)} skipped=${dp.skipped.length}`);
462
+ for (const s of dp.skipped.slice(0, 20))
463
+ console.log(` skipped ${s.path}: ${s.reason}`);
464
+ }
465
+ await built.graphStore.close();
466
+ return;
467
+ }
220
468
  // Determine the alias list (multi-org modes).
221
469
  let aliases = null;
222
470
  if (opts.all) {
@@ -248,14 +496,27 @@ export async function ingestCmd(opts) {
248
496
  }
249
497
  if (aliases !== null && aliases.length > 0) {
250
498
  const list = aliases;
251
- const summary = await multiOrgIngest({
252
- aliases: list,
253
- parallel: Boolean(opts.parallel),
254
- logger,
255
- buildOpts: (alias) => buildSingleIngestOpts(alias, opts, logger, { resolveOrg }),
256
- });
499
+ const build = (alias) => buildSingleIngestOpts(alias, opts, logger, { resolveOrg, onResolved: locks.take });
500
+ // One embedding worker (one model, one ORT arena) shared by every org.
501
+ const workerUrl = defaultEmbeddingWorkerUrl();
502
+ const sharedPool = workerUrl ? new EmbeddingPool({ workerUrl }) : null;
503
+ let summary;
504
+ try {
505
+ summary = await multiOrgIngest({
506
+ aliases: list,
507
+ parallel: Boolean(opts.parallel),
508
+ logger,
509
+ buildOpts: sharedPool ? shareEmbedder(build, sharedPool) : build,
510
+ });
511
+ }
512
+ finally {
513
+ await sharedPool?.close();
514
+ }
257
515
  console.log(`\nMulti-org ingest complete (${summary.parallel ? "parallel" : "sequential"}, ${summary.totalElapsedMs}ms):`);
258
516
  printResultsTable(summary.entries, opts.mode ?? "auto");
517
+ for (const e of summary.entries)
518
+ if (e.result)
519
+ reportEmbeddingOutcome(e.result);
259
520
  const failed = summary.entries.filter((e) => e.status === "error").length;
260
521
  if (failed > 0)
261
522
  process.exitCode = 1;
@@ -276,10 +537,14 @@ export async function ingestCmd(opts) {
276
537
  alias = detected;
277
538
  logger.info(`ingest: using default org from sf config: ${alias}`);
278
539
  }
279
- const built = await buildSingleIngestOpts(alias, opts, logger, { resolveOrg });
540
+ const built = await buildSingleIngestOpts(alias, opts, logger, {
541
+ resolveOrg,
542
+ onResolved: locks.take,
543
+ });
280
544
  const startedAt = Date.now();
281
545
  logger.info(`ingest: starting alias=${alias}`);
282
546
  const result = await liveIngest(built);
547
+ reportEmbeddingOutcome(result);
283
548
  logger.info("ingest: capabilities", result.capabilities);
284
549
  logger.info(`ingest: complete mode=${result.mode} members=${result.membersProcessed} deletions=${result.deletions} parseErrors=${result.parseErrors} elapsed=${Date.now() - startedAt}ms`);
285
550
  // Post-ingest auto-retry. If a substantial number of sources skipped
@@ -315,7 +580,12 @@ export async function ingestCmd(opts) {
315
580
  console.log(" Disable with --no-auto-retry-skipped or SFGRAPH_NO_AUTO_RETRY=1.");
316
581
  await new Promise((resolve) => setTimeout(resolve, delayMs));
317
582
  const retryStartedAt = Date.now();
318
- const retryResult = await liveIngest({ ...built, onlyLabels: labels });
583
+ // In-run auto-retry: not an explicit re-run, so the early stop stays on.
584
+ const retryResult = await liveIngest({
585
+ ...built,
586
+ onlyLabels: labels,
587
+ explicitOnlyLabels: false,
588
+ });
319
589
  logger.info(`ingest: auto-retry complete members=${retryResult.membersProcessed} parseErrors=${retryResult.parseErrors} elapsed=${Date.now() - retryStartedAt}ms`);
320
590
  }
321
591
  }
@@ -359,5 +629,49 @@ export async function ingestCmd(opts) {
359
629
  }
360
630
  process.exitCode = 1;
361
631
  }
632
+ finally {
633
+ locks.releaseAll();
634
+ }
635
+ }
636
+ /**
637
+ * `ingest --project <p> --delta`: re-derive only the changed members
638
+ * (core runDeltaIngest prints `delta: +A ~C -D (=U unchanged) in Ns`). A
639
+ * graph without member rows (older sfgraph, interrupted full ingest) gets a
640
+ * full rebuild instead. The caller's lock covers both.
641
+ */
642
+ async function projectDelta(opts, logger, takeLock) {
643
+ const { runDeltaIngest } = await import("@ryanstark24/sfgraph-core");
644
+ const built = await buildProjectIngestOpts({ ...opts, rebuild: false }, logger, {
645
+ onResolved: takeLock,
646
+ });
647
+ const store = built.graphStore;
648
+ let open = true;
649
+ try {
650
+ const r = await runDeltaIngest({
651
+ ...built,
652
+ graphStore: store,
653
+ preResolved: built.preResolved,
654
+ metadataSource: built.metadataSource,
655
+ fullRebuild: async () => {
656
+ await store.close();
657
+ open = false;
658
+ const full = await buildProjectIngestOpts({ ...opts, rebuild: true, delta: false }, logger);
659
+ try {
660
+ const result = await liveIngest(full);
661
+ reportEmbeddingOutcome(result);
662
+ console.log(`ingest --project: ${result.membersProcessed} members, ${result.parseErrors} parse errors -> ${full.preResolved?.orgId}`);
663
+ }
664
+ finally {
665
+ await full.graphStore.close();
666
+ }
667
+ },
668
+ });
669
+ if (r.result)
670
+ reportEmbeddingOutcome(r.result);
671
+ }
672
+ finally {
673
+ if (open)
674
+ await store.close();
675
+ }
362
676
  }
363
677
  //# sourceMappingURL=ingest.js.map