@saptools/service-flow 0.1.4 → 0.1.5

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/cli.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  parsePackageJson,
11
11
  parseServiceBindings,
12
12
  trace
13
- } from "./chunk-JY6GBGZT.js";
13
+ } from "./chunk-I33ASGX3.js";
14
14
 
15
15
  // src/cli.ts
16
16
  import { Command } from "commander";
@@ -84,14 +84,13 @@ function createWorkspaceConfig(rootPath, dbPath, ignore = [...DEFAULT_IGNORES])
84
84
  }
85
85
 
86
86
  // src/db/connection.ts
87
- import { execFileSync } from "child_process";
88
87
  import fs2 from "fs";
89
88
  import path2 from "path";
90
89
 
91
90
  // src/db/schema.ts
92
91
  var schemaSql = `
93
92
  CREATE TABLE IF NOT EXISTS workspaces (id INTEGER PRIMARY KEY, root_path TEXT UNIQUE NOT NULL, db_path TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
94
- CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
93
+ CREATE TABLE IF NOT EXISTS repositories (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, name TEXT NOT NULL, absolute_path TEXT NOT NULL, relative_path TEXT NOT NULL, package_name TEXT, package_version TEXT, dependencies_json TEXT DEFAULT '{}', kind TEXT NOT NULL, is_git_repo INTEGER NOT NULL, last_indexed_at TEXT, index_status TEXT DEFAULT 'pending', error_count INTEGER DEFAULT 0, fingerprint TEXT, UNIQUE(workspace_id, absolute_path), FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
95
94
  CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, relative_path TEXT NOT NULL, extension TEXT NOT NULL, sha256 TEXT NOT NULL, size_bytes INTEGER NOT NULL, last_indexed_at TEXT NOT NULL, UNIQUE(repo_id, relative_path), FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
96
95
  CREATE TABLE IF NOT EXISTS cds_requires (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, alias TEXT NOT NULL, kind TEXT, model TEXT, destination TEXT, service_path TEXT, request_timeout INTEGER, raw_json TEXT NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
97
96
  CREATE TABLE IF NOT EXISTS cds_services (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, namespace TEXT, service_name TEXT NOT NULL, qualified_name TEXT NOT NULL, service_path TEXT NOT NULL, is_extend INTEGER NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE);
@@ -102,9 +101,9 @@ CREATE TABLE IF NOT EXISTS handler_methods (id INTEGER PRIMARY KEY, handler_clas
102
101
  CREATE TABLE IF NOT EXISTS handler_registrations (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, handler_class_id INTEGER, registration_file TEXT NOT NULL, registration_line INTEGER NOT NULL, registration_kind TEXT NOT NULL, confidence REAL NOT NULL, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(handler_class_id) REFERENCES handler_classes(id) ON DELETE SET NULL);
103
102
  CREATE TABLE IF NOT EXISTS service_bindings (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, symbol_id INTEGER, variable_name TEXT NOT NULL, alias TEXT, alias_expr TEXT, destination_expr TEXT, service_path_expr TEXT, is_dynamic INTEGER NOT NULL, placeholders_json TEXT NOT NULL, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, helper_chain_json TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(symbol_id) REFERENCES symbols(id) ON DELETE SET NULL);
104
103
  CREATE TABLE IF NOT EXISTS outbound_calls (id INTEGER PRIMARY KEY, repo_id INTEGER NOT NULL, source_symbol_id INTEGER, call_type TEXT NOT NULL, service_binding_id INTEGER, method TEXT, operation_path_expr TEXT, query_entity TEXT, event_name_expr TEXT, payload_summary TEXT, source_file TEXT NOT NULL, source_line INTEGER NOT NULL, confidence REAL NOT NULL, unresolved_reason TEXT, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(source_symbol_id) REFERENCES symbols(id) ON DELETE SET NULL, FOREIGN KEY(service_binding_id) REFERENCES service_bindings(id) ON DELETE SET NULL);
105
- CREATE TABLE IF NOT EXISTS graph_edges (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, edge_type TEXT NOT NULL, from_kind TEXT NOT NULL, from_id TEXT NOT NULL, to_kind TEXT NOT NULL, to_id TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, is_dynamic INTEGER NOT NULL, unresolved_reason TEXT);
106
- CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL);
107
- CREATE TABLE IF NOT EXISTS diagnostics (id INTEGER PRIMARY KEY, repo_id INTEGER, file_id INTEGER, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, source_file TEXT, source_line INTEGER);
104
+ CREATE TABLE IF NOT EXISTS graph_edges (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, edge_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unresolved', from_kind TEXT NOT NULL, from_id TEXT NOT NULL, to_kind TEXT NOT NULL, to_id TEXT NOT NULL, confidence REAL NOT NULL, evidence_json TEXT NOT NULL, is_dynamic INTEGER NOT NULL, unresolved_reason TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
105
+ CREATE TABLE IF NOT EXISTS index_runs (id INTEGER PRIMARY KEY, workspace_id INTEGER NOT NULL, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, repo_count INTEGER NOT NULL, file_count INTEGER NOT NULL, diagnostic_count INTEGER NOT NULL, FOREIGN KEY(workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE);
106
+ CREATE TABLE IF NOT EXISTS diagnostics (id INTEGER PRIMARY KEY, repo_id INTEGER, file_id INTEGER, severity TEXT NOT NULL, code TEXT NOT NULL, message TEXT NOT NULL, source_file TEXT, source_line INTEGER, FOREIGN KEY(repo_id) REFERENCES repositories(id) ON DELETE CASCADE, FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE SET NULL);
108
107
  CREATE INDEX IF NOT EXISTS idx_repo_name ON repositories(name);
109
108
  CREATE INDEX IF NOT EXISTS idx_service_path ON cds_services(service_path);
110
109
  CREATE INDEX IF NOT EXISTS idx_operation_name ON cds_operations(operation_name, operation_path);
@@ -113,89 +112,102 @@ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(kind, name, path, rep
113
112
  `;
114
113
 
115
114
  // src/db/migrations.ts
115
+ var CURRENT_SCHEMA_VERSION = 2;
116
+ function hasColumn(db, table, column) {
117
+ return db.prepare(`PRAGMA table_info(${table})`).all().some((row) => row.name === column);
118
+ }
119
+ function userVersion(db) {
120
+ const row = db.pragma("user_version")[0];
121
+ return Number(row?.user_version ?? 0);
122
+ }
116
123
  function migrate(db) {
117
- db.exec(schemaSql);
118
- const columns = db.prepare("PRAGMA table_info(service_bindings)").all();
119
- if (!columns.some((column) => column.name === "helper_chain_json"))
120
- db.prepare(
121
- "ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT"
122
- ).run();
123
- if (!columns.some((column) => column.name === "alias_expr"))
124
- db.prepare("ALTER TABLE service_bindings ADD COLUMN alias_expr TEXT").run();
124
+ db.transaction(() => {
125
+ db.exec(schemaSql);
126
+ if (!hasColumn(db, "service_bindings", "helper_chain_json")) db.prepare("ALTER TABLE service_bindings ADD COLUMN helper_chain_json TEXT").run();
127
+ if (!hasColumn(db, "service_bindings", "alias_expr")) db.prepare("ALTER TABLE service_bindings ADD COLUMN alias_expr TEXT").run();
128
+ if (!hasColumn(db, "repositories", "fingerprint")) db.prepare("ALTER TABLE repositories ADD COLUMN fingerprint TEXT").run();
129
+ if (!hasColumn(db, "graph_edges", "status")) db.prepare("ALTER TABLE graph_edges ADD COLUMN status TEXT NOT NULL DEFAULT 'unresolved'").run();
130
+ db.prepare("UPDATE graph_edges SET status=CASE WHEN edge_type='REMOTE_CALL_RESOLVES_TO_OPERATION' THEN 'resolved' WHEN edge_type IN ('HANDLER_RUNS_DB_QUERY','HANDLER_CALLS_EXTERNAL_HTTP','HANDLER_EMITS_EVENT','EVENT_CONSUMED_BY_HANDLER') THEN 'terminal' WHEN edge_type='DYNAMIC_EDGE_CANDIDATE' THEN 'dynamic' ELSE status END").run();
131
+ db.pragma(`user_version = ${Math.max(userVersion(db), CURRENT_SCHEMA_VERSION)}`);
132
+ });
125
133
  }
126
134
 
127
135
  // src/db/connection.ts
128
- function quote(value) {
129
- if (value === null || value === void 0) return "NULL";
130
- if (typeof value === "number")
131
- return Number.isFinite(value) ? String(value) : "NULL";
132
- if (typeof value === "boolean") return value ? "1" : "0";
133
- return `'${String(value).replaceAll("'", "''")}'`;
134
- }
135
- function bind(sql, params) {
136
- let index = 0;
137
- return sql.replaceAll("?", () => quote(params[index++]));
138
- }
139
- function isBusy(error) {
140
- return error instanceof Error && /SQLITE_BUSY|database is locked|database is busy/i.test(error.message);
141
- }
142
- function sleep(ms) {
143
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
144
- }
145
- function call(dbPath, args, attempt = 0) {
136
+ function loadSqlite() {
146
137
  try {
147
- return execFileSync("sqlite3", [dbPath, ...args], {
148
- encoding: "utf8",
149
- maxBuffer: 128 * 1024 * 1024,
150
- env: { ...process.env, SQLITE_BUSY_TIMEOUT: "10000" }
151
- });
138
+ return process.getBuiltinModule("node:sqlite");
152
139
  } catch (error) {
153
- if (isBusy(error) && attempt < 5) {
154
- sleep(50 * 2 ** attempt);
155
- return call(dbPath, args, attempt + 1);
156
- }
157
- if (isBusy(error))
158
- throw new Error(
159
- "SQLite database is busy or locked. Another service-flow writer may be active; retry after it finishes.",
160
- { cause: error }
161
- );
162
- throw error;
140
+ throw new Error(
141
+ "service-flow requires a persistent SQLite driver. This build uses node:sqlite; run on Node.js with node:sqlite support or install a package build with its native SQLite dependency.",
142
+ { cause: error }
143
+ );
163
144
  }
164
145
  }
165
- function jsonRows(dbPath, sql) {
166
- const out = call(dbPath, ["-json", sql]);
167
- if (!out.trim()) return [];
168
- return JSON.parse(out);
146
+ function bindParams(params) {
147
+ return params.map((param) => {
148
+ if (param === void 0 || param === null) return null;
149
+ if (typeof param === "string" || typeof param === "number" || typeof param === "bigint" || Buffer.isBuffer(param)) return param;
150
+ if (typeof param === "boolean") return param ? 1 : 0;
151
+ return JSON.stringify(param);
152
+ });
169
153
  }
170
- function openDatabase(dbPath) {
154
+ function openDatabase(dbPath, options = {}) {
171
155
  fs2.mkdirSync(path2.dirname(dbPath), { recursive: true });
156
+ const sqlite = loadSqlite();
157
+ const native = new sqlite.DatabaseSync(dbPath, { readOnly: Boolean(options.readonly) });
158
+ let inTransaction = false;
172
159
  const db = {
173
160
  path: dbPath,
161
+ readonly: Boolean(options.readonly),
174
162
  exec(sql) {
175
- call(dbPath, [sql]);
163
+ native.exec(sql);
176
164
  },
177
165
  prepare(sql) {
166
+ const statement = native.prepare(sql);
178
167
  return {
179
- run: (...params) => {
180
- call(dbPath, [bind(sql, params)]);
181
- return { changes: 0 };
182
- },
183
- get: (...params) => jsonRows(dbPath, bind(sql, params))[0],
184
- all: (...params) => jsonRows(dbPath, bind(sql, params))
168
+ run: (...params) => statement.run(...bindParams(params)),
169
+ get: (...params) => statement.get(...bindParams(params)),
170
+ all: (...params) => statement.all(...bindParams(params))
185
171
  };
186
172
  },
187
173
  pragma(sql) {
188
- call(dbPath, [`PRAGMA ${sql}`]);
174
+ const normalized = sql.trim().replace(/;$/, "");
175
+ if (/=/.test(normalized)) {
176
+ native.exec(`PRAGMA ${normalized}`);
177
+ return [];
178
+ }
179
+ return native.prepare(`PRAGMA ${normalized}`).all();
180
+ },
181
+ transaction(fn) {
182
+ if (inTransaction) return fn();
183
+ inTransaction = true;
184
+ native.exec("BEGIN IMMEDIATE");
185
+ try {
186
+ const result = fn();
187
+ native.exec("COMMIT");
188
+ return result;
189
+ } catch (error) {
190
+ native.exec("ROLLBACK");
191
+ throw error;
192
+ } finally {
193
+ inTransaction = false;
194
+ }
189
195
  },
190
196
  close() {
197
+ native.close();
191
198
  }
192
199
  };
193
200
  db.pragma("busy_timeout = 10000");
194
- db.pragma("journal_mode = WAL");
195
201
  db.pragma("foreign_keys = ON");
196
- migrate(db);
202
+ if (!options.readonly) {
203
+ db.pragma("journal_mode = WAL");
204
+ if (options.migrate !== false) migrate(db);
205
+ }
197
206
  return db;
198
207
  }
208
+ function openReadOnlyDatabase(dbPath) {
209
+ return openDatabase(dbPath, { readonly: true, migrate: false });
210
+ }
199
211
 
200
212
  // src/db/repositories.ts
201
213
  function upsertWorkspace(db, rootPath, dbPath) {
@@ -449,6 +461,9 @@ import { readFile } from "fs/promises";
449
461
  async function sha256File(filePath) {
450
462
  return createHash("sha256").update(await readFile(filePath)).digest("hex");
451
463
  }
464
+ function sha256Text(text) {
465
+ return createHash("sha256").update(text).digest("hex");
466
+ }
452
467
 
453
468
  // src/indexer/incremental-index.ts
454
469
  async function recordFile(db, repoId, repoPath, relativeFile) {
@@ -474,10 +489,12 @@ function errorMessage(error) {
474
489
 
475
490
  // src/indexer/repository-indexer.ts
476
491
  async function indexRepository(db, repo, force) {
477
- void force;
478
492
  let diagnostics = 0;
479
493
  let files = 0;
494
+ const sourceFiles = await findSourceFiles(repo.absolute_path);
480
495
  const facts = await parsePackageJson(repo.absolute_path);
496
+ const fingerprint = await repositoryFingerprint(repo.absolute_path, sourceFiles, facts.dependencies, Boolean(force));
497
+ if (!force && repo.fingerprint === fingerprint) return { fileCount: 0, diagnosticCount: 0, skipped: true };
481
498
  const kind = await classifyRepository(repo.absolute_path, facts);
482
499
  db.prepare(
483
500
  "UPDATE repositories SET package_name=?, package_version=?, dependencies_json=?, kind=?, index_status=? WHERE id=?"
@@ -491,7 +508,6 @@ async function indexRepository(db, repo, force) {
491
508
  );
492
509
  clearRepoFacts(db, repo.id);
493
510
  insertRequires(db, repo.id, facts.cdsRequires);
494
- const sourceFiles = await findSourceFiles(repo.absolute_path);
495
511
  for (const file of sourceFiles) {
496
512
  try {
497
513
  await recordFile(db, repo.id, repo.absolute_path, file);
@@ -526,14 +542,15 @@ async function indexRepository(db, repo, force) {
526
542
  }
527
543
  }
528
544
  db.prepare(
529
- "UPDATE repositories SET last_indexed_at=?, index_status=?, error_count=? WHERE id=?"
545
+ "UPDATE repositories SET last_indexed_at=?, index_status=?, error_count=?, fingerprint=? WHERE id=?"
530
546
  ).run(
531
547
  (/* @__PURE__ */ new Date()).toISOString(),
532
548
  diagnostics ? "partial" : "indexed",
533
549
  diagnostics,
550
+ fingerprint,
534
551
  repo.id
535
552
  );
536
- return { fileCount: files, diagnosticCount: diagnostics };
553
+ return { fileCount: files, diagnosticCount: diagnostics, skipped: false };
537
554
  }
538
555
  async function findSourceFiles(root) {
539
556
  const out = [];
@@ -557,6 +574,15 @@ function isDefaultTestFile(relativeFile) {
557
574
  return true;
558
575
  return /\.(test|spec)\.[jt]s$/.test(parts.at(-1) ?? "");
559
576
  }
577
+ async function repositoryFingerprint(root, files, dependencies, force) {
578
+ void force;
579
+ const entries = ["schema:2", `deps:${JSON.stringify(Object.entries(dependencies).sort())}`];
580
+ for (const file of files) {
581
+ const content = await fs5.readFile(path5.join(root, file), "utf8").catch(() => "");
582
+ entries.push(`${file}:${sha256Text(content)}`);
583
+ }
584
+ return sha256Text(entries.join("\n"));
585
+ }
560
586
 
561
587
  // src/indexer/workspace-indexer.ts
562
588
  async function indexWorkspace(db, workspaceId, options) {
@@ -569,10 +595,12 @@ async function indexWorkspace(db, workspaceId, options) {
569
595
  );
570
596
  let fileCount = 0;
571
597
  let diagnosticCount = 0;
598
+ let skippedCount = 0;
572
599
  for (const repo of repos) {
573
600
  const result = await indexRepository(db, repo, options.force);
574
601
  fileCount += result.fileCount;
575
602
  diagnosticCount += result.diagnosticCount;
603
+ skippedCount += result.skipped ? 1 : 0;
576
604
  }
577
605
  db.prepare(
578
606
  "UPDATE index_runs SET finished_at=?, status=?, file_count=?, diagnostic_count=? WHERE id=?"
@@ -583,7 +611,7 @@ async function indexWorkspace(db, workspaceId, options) {
583
611
  diagnosticCount,
584
612
  runId
585
613
  );
586
- return { repoCount: repos.length, fileCount, diagnosticCount };
614
+ return { repoCount: repos.length, indexedCount: repos.length - skippedCount, skippedCount, fileCount, diagnosticCount };
587
615
  }
588
616
 
589
617
  // src/trace/selectors.ts
@@ -684,11 +712,22 @@ async function withWorkspace(workspace, fn) {
684
712
  db.close();
685
713
  }
686
714
  }
715
+ async function withReadOnlyWorkspace(workspace, fn) {
716
+ const config = await loadWorkspaceConfig(workspace);
717
+ const db = openReadOnlyDatabase(config.dbPath);
718
+ try {
719
+ const row = getWorkspace(db, config.rootPath);
720
+ if (!row) throw new Error(`Workspace is not initialized in ${config.dbPath}`);
721
+ return await fn(db, row.id, config.rootPath);
722
+ } finally {
723
+ db.close();
724
+ }
725
+ }
687
726
  function createProgram() {
688
727
  const program = new Command();
689
728
  program.name("service-flow").description(
690
729
  "Trace SAP CAP service-to-service flows across multi-repository workspaces"
691
- ).version("0.1.3");
730
+ ).version("0.1.5");
692
731
  program.command("init").argument("<workspace>").option("--db <path>").option("--ignore <pattern...>").action(
693
732
  (workspace, opts) => void init(workspace, opts).catch(fail)
694
733
  );
@@ -699,7 +738,7 @@ function createProgram() {
699
738
  force: Boolean(opts.force)
700
739
  });
701
740
  process.stdout.write(
702
- `Indexed ${r.repoCount} repositories, ${r.fileCount} files, ${r.diagnosticCount} diagnostics
741
+ `Indexed ${r.indexedCount} repositories, skipped ${r.skippedCount}, ${r.fileCount} files, ${r.diagnosticCount} diagnostics
703
742
  `
704
743
  );
705
744
  }).catch(fail)
@@ -708,13 +747,13 @@ function createProgram() {
708
747
  (opts) => void withWorkspace(opts.workspace, (db, workspaceId) => {
709
748
  const r = linkWorkspace(db, workspaceId);
710
749
  process.stdout.write(
711
- `Linked ${r.edgeCount} edges, ${r.unresolvedCount} unresolved
750
+ `Linked ${r.edgeCount} edges: ${r.resolvedCount} remote resolved, ${r.unresolvedCount} remote unresolved, ${r.ambiguousCount} ambiguous, ${r.dynamicCount} dynamic, ${r.terminalCount} terminal
712
751
  `
713
752
  );
714
753
  }).catch(fail)
715
754
  );
716
755
  program.command("trace").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--handler <name>").option("--depth <n>", "trace depth", "25").option("--format <format>", "table|json|mermaid", "table").option("--include-external").option("--include-db").option("--include-async").option("--var <key=value>", "dynamic variable", collect, []).action(
717
- (opts) => void withWorkspace(opts.workspace, (db) => {
756
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
718
757
  const result = trace(
719
758
  db,
720
759
  {
@@ -739,7 +778,7 @@ function createProgram() {
739
778
  );
740
779
  const list = program.command("list");
741
780
  list.command("repos").option("--workspace <path>").action(
742
- (opts) => void withWorkspace(
781
+ (opts) => void withReadOnlyWorkspace(
743
782
  opts.workspace,
744
783
  (db) => process.stdout.write(
745
784
  renderJson(
@@ -753,7 +792,7 @@ function createProgram() {
753
792
  ).catch(fail)
754
793
  );
755
794
  list.command("services").option("--workspace <path>").option("--repo <name>").action(
756
- (opts) => void withWorkspace(opts.workspace, (db) => {
795
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
757
796
  const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
758
797
  const rows = db.prepare(
759
798
  "SELECT r.name repo,s.service_path servicePath,s.qualified_name qualifiedName FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path"
@@ -762,7 +801,7 @@ function createProgram() {
762
801
  }).catch(fail)
763
802
  );
764
803
  list.command("operations").option("--workspace <path>").option("--repo <name>").option("--service <path>").action(
765
- (opts) => void withWorkspace(opts.workspace, (db) => {
804
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
766
805
  const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
767
806
  const rows = db.prepare(
768
807
  "SELECT r.name repo,s.service_path servicePath,o.operation_name operation,o.operation_path path FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)"
@@ -771,7 +810,7 @@ function createProgram() {
771
810
  }).catch(fail)
772
811
  );
773
812
  list.command("calls").option("--workspace <path>").option("--repo <name>").option("--operation <name>").action(
774
- (opts) => void withWorkspace(opts.workspace, (db) => {
813
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
775
814
  const repo = opts.repo ? repoByName(db, opts.repo) : void 0;
776
815
  const rows = db.prepare(
777
816
  "SELECT r.name repo,c.call_type type,c.operation_path_expr path,c.source_file file,c.source_line line FROM outbound_calls c JOIN repositories r ON r.id=c.repo_id WHERE (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)"
@@ -786,8 +825,8 @@ function createProgram() {
786
825
  process.stdout.write(renderJson(rows));
787
826
  }).catch(fail)
788
827
  );
789
- program.command("graph").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--format <format>", "mermaid|json", "mermaid").action(
790
- (opts) => void withWorkspace(opts.workspace, (db) => {
828
+ program.command("graph").option("--workspace <path>").option("--repo <name>").option("--operation <name>").option("--service <path>").option("--path <operationPath>").option("--format <format>", "mermaid|json", "mermaid").option("--var <key=value>", "dynamic variable", collect, []).action(
829
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
791
830
  const result = trace(
792
831
  db,
793
832
  {
@@ -800,7 +839,8 @@ function createProgram() {
800
839
  depth: 100,
801
840
  includeAsync: true,
802
841
  includeDb: true,
803
- includeExternal: true
842
+ includeExternal: true,
843
+ vars: parseVars(opts.var)
804
844
  }
805
845
  );
806
846
  process.stdout.write(
@@ -810,7 +850,7 @@ function createProgram() {
810
850
  );
811
851
  const inspect = program.command("inspect");
812
852
  inspect.command("repo").argument("<name>").option("--workspace <path>").action(
813
- (name, opts) => void withWorkspace(
853
+ (name, opts) => void withReadOnlyWorkspace(
814
854
  opts.workspace,
815
855
  (db) => process.stdout.write(
816
856
  renderJson(repoByName(db, name) ?? { error: "repo not found" })
@@ -818,7 +858,7 @@ function createProgram() {
818
858
  ).catch(fail)
819
859
  );
820
860
  inspect.command("operation").argument("<selector>").option("--workspace <path>").action(
821
- (selector, opts) => void withWorkspace(opts.workspace, (db) => {
861
+ (selector, opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
822
862
  const rows = db.prepare(
823
863
  "SELECT * FROM cds_operations WHERE operation_name=? OR operation_path=?"
824
864
  ).all(selector, selector);
@@ -826,24 +866,27 @@ function createProgram() {
826
866
  }).catch(fail)
827
867
  );
828
868
  program.command("doctor").option("--workspace <path>").option("--strict").action(
829
- (opts) => void withWorkspace(opts.workspace, (db) => {
869
+ (opts) => void withReadOnlyWorkspace(opts.workspace, (db) => {
830
870
  const diagnostics = db.prepare(
831
871
  "SELECT severity,code,message,source_file sourceFile,source_line sourceLine FROM diagnostics ORDER BY id"
832
- ).all(Boolean(opts.strict));
872
+ ).all();
833
873
  const health = db.prepare(
834
874
  `SELECT 'info' severity,'entity_only_service' code,'CDS service has no action/function/event operations; this can be valid for entity-only services' message,s.source_file sourceFile,s.source_line sourceLine
835
875
  FROM cds_services s LEFT JOIN cds_operations o ON o.service_id=s.id WHERE o.id IS NULL AND ?
836
876
  UNION ALL
837
877
  SELECT 'warning','extend_service_unresolved_base','Extend service has no indexed local operations; verify base service resolution',s.source_file,s.source_line
838
- FROM cds_services s LEFT JOIN cds_operations o ON o.service_id=s.id WHERE o.id IS NULL AND s.is_extend=1
878
+ FROM cds_services s LEFT JOIN cds_operations o ON o.service_id=s.id WHERE o.id IS NULL AND s.is_extend=1 AND ?
839
879
  UNION ALL
840
880
  SELECT 'warning','handler_without_service','Repository has handlers but no CDS services',hc.source_file,hc.source_line
841
881
  FROM handler_classes hc JOIN repositories r ON r.id=hc.repo_id
842
882
  WHERE r.kind IN ('cap-service','mixed') AND NOT EXISTS (SELECT 1 FROM cds_services s WHERE s.repo_id=hc.repo_id)
843
883
  UNION ALL
844
884
  SELECT 'warning','search_index_empty','Search index is empty after indexing',NULL,NULL
845
- WHERE NOT EXISTS (SELECT 1 FROM search_index)`
846
- ).all(Boolean(opts.strict));
885
+ WHERE NOT EXISTS (SELECT 1 FROM search_index)
886
+ UNION ALL
887
+ SELECT 'error','foreign_key_violation','SQLite foreign_key_check reported integrity failures',NULL,NULL
888
+ WHERE EXISTS (SELECT 1 FROM pragma_foreign_key_check)`
889
+ ).all(Boolean(opts.strict), Boolean(opts.strict));
847
890
  const allDiagnostics = [...diagnostics, ...health];
848
891
  process.stdout.write(
849
892
  allDiagnostics.length ? renderJson(allDiagnostics) : `${pc.green("No diagnostics recorded")}