@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.
- package/dist/commands/ingest.d.ts +73 -0
- package/dist/commands/ingest.d.ts.map +1 -1
- package/dist/commands/ingest.js +334 -20
- package/dist/commands/ingest.js.map +1 -1
- package/dist/commands/serve.d.ts +7 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +59 -1
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/skip-labels.d.ts +11 -0
- package/dist/commands/skip-labels.d.ts.map +1 -0
- package/dist/commands/skip-labels.js +15 -0
- package/dist/commands/skip-labels.js.map +1 -0
- package/dist/commands/telemetry.js +1 -1
- package/dist/commands/telemetry.js.map +1 -1
- package/dist/heap.d.ts +24 -0
- package/dist/heap.d.ts.map +1 -0
- package/dist/heap.js +45 -0
- package/dist/heap.js.map +1 -0
- package/dist/index.d.ts +13 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/dist/__tests__/diagnose.test.d.ts +0 -2
- package/dist/__tests__/diagnose.test.d.ts.map +0 -1
- package/dist/__tests__/diagnose.test.js +0 -272
- package/dist/__tests__/diagnose.test.js.map +0 -1
- package/dist/__tests__/doctor.test.d.ts +0 -2
- package/dist/__tests__/doctor.test.d.ts.map +0 -1
- package/dist/__tests__/doctor.test.js +0 -64
- package/dist/__tests__/doctor.test.js.map +0 -1
- package/dist/__tests__/ingest-db-guard.test.d.ts +0 -2
- package/dist/__tests__/ingest-db-guard.test.d.ts.map +0 -1
- package/dist/__tests__/ingest-db-guard.test.js +0 -110
- package/dist/__tests__/ingest-db-guard.test.js.map +0 -1
- package/dist/__tests__/ingest-multi.test.d.ts +0 -2
- package/dist/__tests__/ingest-multi.test.d.ts.map +0 -1
- package/dist/__tests__/ingest-multi.test.js +0 -51
- package/dist/__tests__/ingest-multi.test.js.map +0 -1
- package/dist/__tests__/install-dry-run.test.d.ts +0 -2
- package/dist/__tests__/install-dry-run.test.d.ts.map +0 -1
- package/dist/__tests__/install-dry-run.test.js +0 -29
- package/dist/__tests__/install-dry-run.test.js.map +0 -1
- package/dist/__tests__/install-e2e.test.d.ts +0 -2
- package/dist/__tests__/install-e2e.test.d.ts.map +0 -1
- package/dist/__tests__/install-e2e.test.js +0 -33
- package/dist/__tests__/install-e2e.test.js.map +0 -1
- package/dist/__tests__/mcp-config.test.d.ts +0 -2
- package/dist/__tests__/mcp-config.test.d.ts.map +0 -1
- package/dist/__tests__/mcp-config.test.js +0 -104
- package/dist/__tests__/mcp-config.test.js.map +0 -1
- package/dist/__tests__/rebuild.test.d.ts +0 -2
- package/dist/__tests__/rebuild.test.d.ts.map +0 -1
- package/dist/__tests__/rebuild.test.js +0 -180
- package/dist/__tests__/rebuild.test.js.map +0 -1
- package/dist/__tests__/serve-guard.test.d.ts +0 -2
- package/dist/__tests__/serve-guard.test.d.ts.map +0 -1
- package/dist/__tests__/serve-guard.test.js +0 -82
- package/dist/__tests__/serve-guard.test.js.map +0 -1
- package/dist/__tests__/snapshot.test.d.ts +0 -2
- package/dist/__tests__/snapshot.test.d.ts.map +0 -1
- package/dist/__tests__/snapshot.test.js +0 -139
- package/dist/__tests__/snapshot.test.js.map +0 -1
- package/dist/__tests__/telemetry.test.d.ts +0 -2
- package/dist/__tests__/telemetry.test.d.ts.map +0 -1
- package/dist/__tests__/telemetry.test.js +0 -53
- 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":"
|
|
1
|
+
{"version":3,"file":"ingest.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EAAc,cAAc,EAAuB,MAAM,2BAA2B,CAAC;AACjG,OAAO,EACL,aAAa,EAId,MAAM,6BAA6B,CAAC;AAGrC,MAAM,WAAW,UAAU;IACzB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,0EAA0E;IAC1E,KAAK,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC5B,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;+EAC2E;IAC3E,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,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;AAyND;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAMxF;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE;IAC7C,UAAU,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACpE,GAAG,IAAI,CAOP;AAED;yEACyE;AACzE,wBAAgB,aAAa,CAC3B,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,EACjD,QAAQ,EAAE,WAAW,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,GAChD,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAK5C;AAED;+EAC+E;AAC/E,wBAAsB,sBAAsB,CAC1C,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,aAAa,EACrB,IAAI,GAAE;IAAE,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GACjE,OAAO,CAAC,cAAc,CAAC,CAuBzB;AAWD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAe7D;AAED,4EAA4E;AAC5E,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAyBtC;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAuB7F;AA4DD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,CAAC,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,OAAO,KAAK,IAAI,CAAA;CAAO,GAAG;IAC/F,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,UAAU,EAAE,MAAM,IAAI,CAAC;CACxB,CAwBA;AAED,wBAAsB,SAAS,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAwP/D"}
|
package/dist/commands/ingest.js
CHANGED
|
@@ -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
|
-
|
|
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 ?? [])
|
|
133
|
+
const labels = retrySkippedLabels(raw.skips ?? []);
|
|
106
134
|
if (labels.length === 0) {
|
|
107
|
-
console.log("--retry-skipped: previous run had
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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, {
|
|
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
|
-
|
|
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
|