@saptools/service-flow 0.1.50 → 0.1.52

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +16 -3
  3. package/TECHNICAL-NOTE.md +10 -1
  4. package/dist/{chunk-52OUS3MO.js → chunk-PTLDSHRC.js} +2663 -363
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +318 -510
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +59 -17
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/000-clean.ts +82 -0
  12. package/src/cli.ts +97 -57
  13. package/src/db/connection.ts +1 -1
  14. package/src/db/migrations.ts +5 -3
  15. package/src/db/repositories.ts +120 -22
  16. package/src/db/schema.ts +7 -2
  17. package/src/index.ts +1 -1
  18. package/src/indexer/repository-indexer.ts +57 -29
  19. package/src/indexer/workspace-indexer.ts +84 -6
  20. package/src/linker/cross-repo-linker.ts +22 -2
  21. package/src/linker/service-resolver.ts +15 -4
  22. package/src/output/table-output.ts +56 -3
  23. package/src/parsers/cds-parser.ts +8 -2
  24. package/src/parsers/decorator-parser.ts +382 -48
  25. package/src/parsers/handler-registration-parser.ts +38 -21
  26. package/src/parsers/imported-wrapper-parser.ts +18 -5
  27. package/src/parsers/outbound-call-parser.ts +25 -8
  28. package/src/parsers/package-json-parser.ts +36 -11
  29. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  30. package/src/parsers/service-binding-parser.ts +6 -3
  31. package/src/parsers/symbol-parser.ts +13 -3
  32. package/src/parsers/ts-project.ts +54 -0
  33. package/src/trace/000-dynamic-target-types.ts +84 -0
  34. package/src/trace/001-dynamic-identity.ts +280 -0
  35. package/src/trace/002-trace-diagnostics.ts +54 -0
  36. package/src/trace/003-dynamic-references.ts +82 -0
  37. package/src/trace/dynamic-branches.ts +45 -0
  38. package/src/trace/dynamic-targets.ts +654 -0
  39. package/src/trace/evidence.ts +391 -22
  40. package/src/trace/selectors.ts +483 -0
  41. package/src/trace/trace-engine.ts +101 -94
  42. package/src/types.ts +39 -1
  43. package/dist/chunk-52OUS3MO.js.map +0 -1
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import ts from 'typescript';
2
+
1
3
  type CallType = 'remote_action' | 'remote_query' | 'remote_entity_read' | 'remote_entity_mutation' | 'remote_entity_delete' | 'remote_entity_media' | 'remote_entity_candidate' | 'local_db_query' | 'external_http' | 'async_emit' | 'async_subscribe' | 'local_service_call' | 'unknown';
2
4
  interface DiscoveredRepository {
3
5
  name: string;
@@ -55,17 +57,36 @@ interface HandlerClassFact {
55
57
  sourceFile: string;
56
58
  sourceLine: number;
57
59
  methods: HandlerMethodFact[];
60
+ hasHandlerDecorator?: boolean;
61
+ classDecoratorNames?: string[];
62
+ observedDecoratorNames?: string[];
63
+ unsupportedDecoratorNames?: string[];
58
64
  }
65
+ type HandlerMethodKind = 'operation' | 'entity_lifecycle' | 'event' | 'unsupported_lifecycle' | 'unsupported_decorator';
66
+ type HandlerLifecyclePhase = 'on' | 'before' | 'after';
67
+ type HandlerLifecycleEvent = 'CREATE' | 'READ' | 'UPDATE' | 'DELETE';
59
68
  interface HandlerMethodFact {
60
69
  methodName: string;
61
70
  decoratorKind: string;
62
71
  decoratorValue?: string;
63
72
  decoratorRawExpression: string;
73
+ handlerKind?: HandlerMethodKind;
74
+ executable?: boolean;
75
+ lifecyclePhase?: HandlerLifecyclePhase;
76
+ lifecycleEvent?: HandlerLifecycleEvent;
64
77
  decoratorResolution: {
65
78
  rawExpression: string;
79
+ decoratorExpression?: string;
80
+ argumentExpression?: string;
81
+ resolvedDecoratorKind?: string;
82
+ decoratorImportSource?: string;
66
83
  resolvedValue?: string;
67
- resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'unresolved';
84
+ resolutionKind: 'literal' | 'const_identifier' | 'enum_member' | 'const_object_property' | 'generated_constant_name' | 'lifecycle_implicit' | 'unresolved';
68
85
  unresolvedReason?: string;
86
+ handlerKind?: HandlerMethodKind;
87
+ executable?: boolean;
88
+ lifecyclePhase?: HandlerLifecyclePhase;
89
+ lifecycleEvent?: HandlerLifecycleEvent;
69
90
  };
70
91
  sourceFile: string;
71
92
  sourceLine: number;
@@ -135,6 +156,19 @@ interface ImplementationHint {
135
156
  candidateFamily?: string;
136
157
  implementationRepo: string;
137
158
  }
159
+ type DynamicMode = 'strict' | 'candidates' | 'infer';
160
+ interface TraceOptions {
161
+ depth: number;
162
+ workspaceId?: number;
163
+ vars?: Record<string, string>;
164
+ includeExternal?: boolean;
165
+ includeDb?: boolean;
166
+ includeAsync?: boolean;
167
+ implementationRepo?: string;
168
+ implementationHints?: ImplementationHint[];
169
+ dynamicMode?: DynamicMode;
170
+ maxDynamicCandidates?: number;
171
+ }
138
172
  interface TraceEdge {
139
173
  step: number;
140
174
  type: string;
@@ -153,17 +187,33 @@ interface TraceResult {
153
187
 
154
188
  declare function discoverRepositories(rootPath: string, ignore: readonly string[]): Promise<DiscoveredRepository[]>;
155
189
 
156
- declare function parsePackageJson(repoPath: string): Promise<PackageFacts>;
190
+ interface ParsePackageJsonOptions {
191
+ strict?: boolean;
192
+ allowMissing?: boolean;
193
+ }
194
+ declare function parsePackageJson(repoPath: string, options?: ParsePackageJsonOptions): Promise<PackageFacts>;
195
+
196
+ interface SourceFileSnapshot {
197
+ repoPath: string;
198
+ filePath: string;
199
+ text: string;
200
+ sizeBytes: number;
201
+ sourceFile: () => ts.SourceFile;
202
+ }
203
+ interface RepositorySourceContext {
204
+ get: (filePath: string) => SourceFileSnapshot | undefined;
205
+ entries: () => SourceFileSnapshot[];
206
+ }
157
207
 
158
- declare function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]>;
208
+ declare function parseCdsFile(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<CdsServiceFact[]>;
159
209
 
160
- declare function parseDecorators(repoPath: string, filePath: string): Promise<HandlerClassFact[]>;
210
+ declare function parseDecorators(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerClassFact[]>;
161
211
 
162
- declare function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]>;
212
+ declare function parseHandlerRegistrations(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerRegistrationFact[]>;
163
213
 
164
- declare function parseServiceBindings(repoPath: string, filePath: string): Promise<ServiceBindingFact[]>;
214
+ declare function parseServiceBindings(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<ServiceBindingFact[]>;
165
215
 
166
- declare function parseOutboundCalls(repoPath: string, filePath: string): Promise<OutboundCallFact[]>;
216
+ declare function parseOutboundCalls(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<OutboundCallFact[]>;
167
217
 
168
218
  declare function parseGeneratedConstants(repoPath: string, filePath: string): Promise<GeneratedConstantFact[]>;
169
219
 
@@ -213,19 +263,11 @@ declare function applyVariables(template: string | undefined, vars: Record<strin
213
263
  declare function extractPlaceholders(template: string | undefined): string[];
214
264
  declare function substituteVariables(template: string | undefined, vars: Record<string, string>): RuntimeSubstitution;
215
265
 
216
- declare function trace(db: Db, start: TraceStart, options: {
217
- depth: number;
218
- vars?: Record<string, string>;
219
- includeExternal?: boolean;
220
- includeDb?: boolean;
221
- includeAsync?: boolean;
222
- implementationRepo?: string;
223
- implementationHints?: ImplementationHint[];
224
- }): TraceResult;
266
+ declare function trace(db: Db, start: TraceStart, options: TraceOptions): TraceResult;
225
267
 
226
268
  declare function parseImplementationHint(value: string): ImplementationHint;
227
269
 
228
270
  declare function redactText(text: string): string;
229
271
  declare function redactValue(value: unknown): unknown;
230
272
 
231
- export { type ImplementationHint, type RuntimeSubstitution, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
273
+ export { type DynamicMode, type ImplementationHint, type RuntimeSubstitution, type TraceOptions, applyVariables, discoverRepositories, extractPlaceholders, linkWorkspace, parseCdsFile, parseDecorators, parseGeneratedConstants, parseHandlerRegistrations, parseImplementationHint, parseOutboundCalls, parsePackageJson, parseServiceBindings, redactText, redactValue, substituteVariables, trace };
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-52OUS3MO.js";
20
+ } from "./chunk-PTLDSHRC.js";
21
21
 
22
22
  // src/parsers/generated-constants-parser.ts
23
23
  import fs from "fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saptools/service-flow",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "description": "Trace SAP CAP service-to-service flows across multi-repository workspaces with runtime-aware graph resolution",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -0,0 +1,82 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { WorkspaceConfig } from '../config/workspace-config.js';
4
+ import { openDatabase } from '../db/connection.js';
5
+ import { getWorkspace, upsertWorkspace } from '../db/repositories.js';
6
+ import { claimIndexRun } from '../indexer/workspace-indexer.js';
7
+ import { errorMessage } from '../utils/diagnostics.js';
8
+
9
+ type CleanConfig = Pick<WorkspaceConfig, 'dbPath' | 'rootPath'>;
10
+
11
+ export async function cleanWorkspaceState(
12
+ config: CleanConfig,
13
+ dbOnly: boolean,
14
+ ): Promise<void> {
15
+ const dbDir = path.resolve(path.dirname(config.dbPath));
16
+ if (!dbOnly) await assertOwnedStateDirectory(dbDir, config.rootPath);
17
+ const runId = claimCleanWriter(config);
18
+ try {
19
+ if (dbOnly) await removeDatabaseFiles(config.dbPath);
20
+ else await fs.rm(dbDir, { recursive: true, force: true });
21
+ } catch (error) {
22
+ await markCleanClaimFailed(config.dbPath, runId, error);
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ function claimCleanWriter(config: CleanConfig): number {
28
+ const db = openDatabase(config.dbPath);
29
+ try {
30
+ const workspaceId = getWorkspace(db, config.rootPath)?.id
31
+ ?? upsertWorkspace(db, config.rootPath, config.dbPath);
32
+ return claimIndexRun(db, workspaceId, 0);
33
+ } finally {
34
+ db.close();
35
+ }
36
+ }
37
+
38
+ async function assertOwnedStateDirectory(
39
+ dbDir: string,
40
+ rootPath: string,
41
+ ): Promise<void> {
42
+ const marker = path.join(dbDir, '.service-flow-state');
43
+ const dangerous = new Set([
44
+ path.parse(dbDir).root,
45
+ '/tmp',
46
+ process.env.HOME ? path.resolve(process.env.HOME) : '',
47
+ path.resolve(rootPath),
48
+ ]);
49
+ const ownsState = await fs.stat(marker)
50
+ .then((stat) => stat.isFile())
51
+ .catch(() => false);
52
+ if (!ownsState || dangerous.has(dbDir))
53
+ throw new Error(
54
+ `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
55
+ );
56
+ }
57
+
58
+ async function removeDatabaseFiles(dbPath: string): Promise<void> {
59
+ for (const suffix of ['-wal', '-shm', '-journal'])
60
+ await fs.rm(`${dbPath}${suffix}`, { force: true });
61
+ await fs.rm(dbPath, { force: true });
62
+ }
63
+
64
+ async function markCleanClaimFailed(
65
+ dbPath: string,
66
+ runId: number,
67
+ error: unknown,
68
+ ): Promise<void> {
69
+ const exists = await fs.stat(dbPath).then(() => true).catch(() => false);
70
+ if (!exists) return;
71
+ const db = openDatabase(dbPath);
72
+ try {
73
+ db.prepare(`UPDATE index_runs SET finished_at=?,status='failed',
74
+ error_message=? WHERE id=? AND status='running'`).run(
75
+ new Date().toISOString(),
76
+ `Clean failed after writer claim: ${errorMessage(error)}`,
77
+ runId,
78
+ );
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
package/src/cli.ts CHANGED
@@ -1,17 +1,16 @@
1
1
  import { Command } from 'commander';
2
- import path from 'node:path';
3
- import fs from 'node:fs/promises';
4
2
  import { DEFAULT_IGNORES } from './config/defaults.js';
5
3
  import {
6
4
  createWorkspaceConfig,
7
5
  loadWorkspaceConfig,
8
6
  saveWorkspaceConfig,
9
7
  } from './config/workspace-config.js';
10
- import { openDatabase, openReadOnlyDatabase } from './db/connection.js';
8
+ import { openDatabase, openReadOnlyDatabase, type Db } from './db/connection.js';
11
9
  import {
12
10
  getWorkspace,
13
11
  listRepositories,
14
- repoByName,
12
+ reposByName,
13
+ type RepoRow,
15
14
  upsertRepository,
16
15
  upsertWorkspace,
17
16
  } from './db/repositories.js';
@@ -22,13 +21,18 @@ import { indexWorkspace } from './indexer/workspace-indexer.js';
22
21
  import { linkWorkspace } from './linker/cross-repo-linker.js';
23
22
  import { doctorDiagnostics, linkUpgradeWarnings } from './cli/doctor.js';
24
23
  import { trace } from './trace/trace-engine.js';
25
- import { parseVars } from './trace/selectors.js';
24
+ import {
25
+ parseVars,
26
+ selectorRepoAmbiguousDiagnostic,
27
+ } from './trace/selectors.js';
26
28
  import { parseImplementationHint } from './trace/implementation-hints.js';
27
29
  import { renderTraceTable } from './output/table-output.js';
28
30
  import { renderTraceJson, renderJson } from './output/json-output.js';
29
31
  import { renderDoctorDiagnostics } from './output/doctor-output.js';
30
32
  import { renderMermaid } from './output/mermaid-output.js';
31
33
  import { VERSION } from './version.js';
34
+ import type { DynamicMode } from './types.js';
35
+ import { cleanWorkspaceState } from './cli/000-clean.js';
32
36
  async function init(
33
37
  workspace: string,
34
38
  options: { db?: string; ignore?: string[] },
@@ -91,6 +95,30 @@ async function withReadOnlyWorkspace<T>(
91
95
  db.close();
92
96
  }
93
97
  }
98
+ function selectRepository(db: Db, selector: string, workspaceId?: number): {
99
+ repo?: RepoRow;
100
+ diagnostic?: Record<string, unknown>;
101
+ } {
102
+ const candidates = reposByName(db, selector, workspaceId);
103
+ if (candidates.length === 1) return { repo: candidates[0] };
104
+ if (candidates.length === 0) return {
105
+ diagnostic: {
106
+ severity: 'warning',
107
+ code: 'selector_repo_not_found',
108
+ message: `Repository selector not found: ${selector}`,
109
+ },
110
+ };
111
+ return {
112
+ diagnostic: selectorRepoAmbiguousDiagnostic(
113
+ selector,
114
+ candidates.map((repo) => ({
115
+ id: repo.id,
116
+ name: repo.name,
117
+ packageName: repo.package_name ?? undefined,
118
+ })),
119
+ ),
120
+ };
121
+ }
94
122
  export function createProgram(): Command {
95
123
  const program = new Command();
96
124
  program
@@ -155,6 +183,8 @@ export function createProgram(): Command {
155
183
  .option('--implementation-repo <name>')
156
184
  .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
157
185
  .option('--var <key=value>', 'dynamic variable', collect, [])
186
+ .option('--dynamic-mode <mode>', 'strict|candidates|infer', 'strict')
187
+ .option('--max-dynamic-candidates <n>', 'maximum dynamic candidates to show', '5')
158
188
  .action(
159
189
  (opts: {
160
190
  workspace?: string;
@@ -171,8 +201,10 @@ export function createProgram(): Command {
171
201
  implementationRepo?: string;
172
202
  implementationHint: string[];
173
203
  var: string[];
204
+ dynamicMode: string;
205
+ maxDynamicCandidates: string;
174
206
  }) =>
175
- void withReadOnlyWorkspace(opts.workspace, (db) => {
207
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
176
208
  const result = trace(
177
209
  db,
178
210
  {
@@ -184,12 +216,15 @@ export function createProgram(): Command {
184
216
  },
185
217
  {
186
218
  depth: Number(opts.depth),
219
+ workspaceId,
187
220
  vars: parseVars(opts.var),
188
221
  includeExternal: Boolean(opts.includeExternal),
189
222
  includeDb: Boolean(opts.includeDb),
190
223
  includeAsync: Boolean(opts.includeAsync),
191
224
  implementationRepo: opts.implementationRepo,
192
225
  implementationHints: opts.implementationHint.map(parseImplementationHint),
226
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
227
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
193
228
  },
194
229
  );
195
230
  process.stdout.write(
@@ -207,10 +242,10 @@ export function createProgram(): Command {
207
242
  .option('--workspace <path>')
208
243
  .action(
209
244
  (opts: { workspace?: string }) =>
210
- void withReadOnlyWorkspace(opts.workspace, (db) =>
245
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
211
246
  process.stdout.write(
212
247
  renderJson(
213
- listRepositories(db).map((r) => ({
248
+ listRepositories(db, workspaceId).map((r) => ({
214
249
  name: r.name,
215
250
  kind: r.kind,
216
251
  packageName: r.package_name,
@@ -225,17 +260,20 @@ export function createProgram(): Command {
225
260
  .option('--repo <name>')
226
261
  .action(
227
262
  (opts: { workspace?: string; repo?: string }) =>
228
- void withReadOnlyWorkspace(opts.workspace, (db) => {
229
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
230
- if (opts.repo && !repo) {
231
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
263
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
264
+ const selection = opts.repo
265
+ ? selectRepository(db, opts.repo, workspaceId)
266
+ : {};
267
+ if (selection.diagnostic) {
268
+ process.stdout.write(renderJson([selection.diagnostic]));
232
269
  return;
233
270
  }
271
+ const repo = selection.repo;
234
272
  const rows = db
235
273
  .prepare(
236
- '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',
274
+ '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 r.workspace_id=? AND (? IS NULL OR s.repo_id=?) ORDER BY r.name,s.service_path',
237
275
  )
238
- .all(repo?.id, repo?.id);
276
+ .all(workspaceId, repo?.id, repo?.id);
239
277
  process.stdout.write(renderJson(rows));
240
278
  }).catch(fail),
241
279
  );
@@ -246,17 +284,20 @@ export function createProgram(): Command {
246
284
  .option('--service <path>')
247
285
  .action(
248
286
  (opts: { workspace?: string; repo?: string; service?: string }) =>
249
- void withReadOnlyWorkspace(opts.workspace, (db) => {
250
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
251
- if (opts.repo && !repo) {
252
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
287
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
288
+ const selection = opts.repo
289
+ ? selectRepository(db, opts.repo, workspaceId)
290
+ : {};
291
+ if (selection.diagnostic) {
292
+ process.stdout.write(renderJson([selection.diagnostic]));
253
293
  return;
254
294
  }
295
+ const repo = selection.repo;
255
296
  const rows = db
256
297
  .prepare(
257
- '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=?)',
298
+ '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 r.workspace_id=? AND (? IS NULL OR s.repo_id=?) AND (? IS NULL OR s.service_path=?)',
258
299
  )
259
- .all(repo?.id, repo?.id, opts.service, opts.service);
300
+ .all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
260
301
  process.stdout.write(renderJson(rows));
261
302
  }).catch(fail),
262
303
  );
@@ -267,17 +308,21 @@ export function createProgram(): Command {
267
308
  .option('--operation <name>')
268
309
  .action(
269
310
  (opts: { workspace?: string; repo?: string; operation?: string }) =>
270
- void withReadOnlyWorkspace(opts.workspace, (db) => {
271
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
272
- if (opts.repo && !repo) {
273
- process.stdout.write(renderJson([{ severity: 'warning', code: 'selector_repo_not_found', message: `Repository selector not found: ${opts.repo}` }]));
311
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
312
+ const selection = opts.repo
313
+ ? selectRepository(db, opts.repo, workspaceId)
314
+ : {};
315
+ if (selection.diagnostic) {
316
+ process.stdout.write(renderJson([selection.diagnostic]));
274
317
  return;
275
318
  }
319
+ const repo = selection.repo;
276
320
  const rows = db
277
321
  .prepare(
278
- '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 ?)',
322
+ '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 r.workspace_id=? AND (? IS NULL OR c.repo_id=?) AND (? IS NULL OR c.operation_path_expr=? OR c.operation_path_expr=? OR c.payload_summary LIKE ?)',
279
323
  )
280
324
  .all(
325
+ workspaceId,
281
326
  repo?.id,
282
327
  repo?.id,
283
328
  opts.operation,
@@ -299,6 +344,8 @@ export function createProgram(): Command {
299
344
  .option('--implementation-repo <name>')
300
345
  .option('--implementation-hint <scope>', 'scoped implementation hint', collect, [])
301
346
  .option('--var <key=value>', 'dynamic variable', collect, [])
347
+ .option('--dynamic-mode <mode>', 'strict|candidates|infer', 'strict')
348
+ .option('--max-dynamic-candidates <n>', 'maximum dynamic candidates to show', '5')
302
349
  .action(
303
350
  (opts: {
304
351
  workspace?: string;
@@ -310,8 +357,10 @@ export function createProgram(): Command {
310
357
  implementationRepo?: string;
311
358
  implementationHint: string[];
312
359
  var: string[];
360
+ dynamicMode: string;
361
+ maxDynamicCandidates: string;
313
362
  }) =>
314
- void withReadOnlyWorkspace(opts.workspace, (db) => {
363
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
315
364
  const result = trace(
316
365
  db,
317
366
  {
@@ -322,12 +371,15 @@ export function createProgram(): Command {
322
371
  },
323
372
  {
324
373
  depth: 100,
374
+ workspaceId,
325
375
  includeAsync: true,
326
376
  includeDb: true,
327
377
  includeExternal: true,
328
378
  vars: parseVars(opts.var),
329
379
  implementationRepo: opts.implementationRepo,
330
380
  implementationHints: opts.implementationHint.map(parseImplementationHint),
381
+ dynamicMode: parseDynamicMode(opts.dynamicMode),
382
+ maxDynamicCandidates: parsePositiveInteger(opts.maxDynamicCandidates, 5),
331
383
  },
332
384
  );
333
385
  process.stdout.write(
@@ -344,11 +396,12 @@ export function createProgram(): Command {
344
396
  .option('--workspace <path>')
345
397
  .action(
346
398
  (name: string, opts: { workspace?: string }) =>
347
- void withReadOnlyWorkspace(opts.workspace, (db) =>
348
- process.stdout.write(
349
- renderJson(repoByName(db, name) ?? { error: 'repo not found' }),
350
- ),
351
- ).catch(fail),
399
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
400
+ const selection = selectRepository(db, name, workspaceId);
401
+ process.stdout.write(renderJson(
402
+ selection.repo ?? selection.diagnostic ?? { error: 'repo not found' },
403
+ ));
404
+ }).catch(fail),
352
405
  );
353
406
  inspect
354
407
  .command('operation')
@@ -356,12 +409,12 @@ export function createProgram(): Command {
356
409
  .option('--workspace <path>')
357
410
  .action(
358
411
  (selector: string, opts: { workspace?: string }) =>
359
- void withReadOnlyWorkspace(opts.workspace, (db) => {
412
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
360
413
  const rows = db
361
414
  .prepare(
362
- 'SELECT * FROM cds_operations WHERE operation_name=? OR operation_path=?',
415
+ 'SELECT o.* FROM cds_operations o JOIN cds_services s ON s.id=o.service_id JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND (o.operation_name=? OR o.operation_path=?)',
363
416
  )
364
- .all(selector, selector);
417
+ .all(workspaceId, selector, selector);
365
418
  process.stdout.write(renderJson(rows));
366
419
  }).catch(fail),
367
420
  );
@@ -386,29 +439,7 @@ export function createProgram(): Command {
386
439
  (opts: { workspace?: string; dbOnly?: boolean }) =>
387
440
  void (async () => {
388
441
  const config = await loadWorkspaceConfig(opts.workspace);
389
- const dbDir = path.resolve(path.dirname(config.dbPath));
390
- const workspaceRoot = path.resolve(config.rootPath);
391
- await fs.rm(config.dbPath, { force: true });
392
- if (!opts.dbOnly) {
393
- const marker = path.join(dbDir, '.service-flow-state');
394
- const dangerous = new Set([
395
- path.parse(dbDir).root,
396
- '/tmp',
397
- process.env.HOME ? path.resolve(process.env.HOME) : '',
398
- workspaceRoot,
399
- ]);
400
- let ownsState: boolean;
401
- try {
402
- ownsState = (await fs.stat(marker)).isFile();
403
- } catch {
404
- ownsState = false;
405
- }
406
- if (!ownsState || dangerous.has(dbDir))
407
- throw new Error(
408
- `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
409
- );
410
- await fs.rm(dbDir, { recursive: true, force: true });
411
- }
442
+ await cleanWorkspaceState(config, Boolean(opts.dbOnly));
412
443
  process.stdout.write('Cleaned service-flow state\n');
413
444
  })().catch(fail),
414
445
  );
@@ -418,6 +449,15 @@ function collect(value: string, previous: string[]): string[] {
418
449
  previous.push(value);
419
450
  return previous;
420
451
  }
452
+ function parseDynamicMode(value: string | undefined): DynamicMode {
453
+ if (value === undefined || value === 'strict') return 'strict';
454
+ if (value === 'candidates' || value === 'infer') return value;
455
+ throw new Error(`Invalid --dynamic-mode ${value}; expected strict, candidates, or infer`);
456
+ }
457
+ function parsePositiveInteger(value: string | undefined, fallback: number): number {
458
+ const parsed = Number(value);
459
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
460
+ }
421
461
  function fail(error: unknown): void {
422
462
  process.stderr.write(
423
463
  `${error instanceof Error ? error.message : String(error)}\n`,
@@ -101,8 +101,8 @@ export function openDatabase(dbPath: string, options: OpenDatabaseOptions = {}):
101
101
  },
102
102
  transaction<T>(fn: () => T): T {
103
103
  if (inTransaction) return fn();
104
- inTransaction = true;
105
104
  native.exec('BEGIN IMMEDIATE');
105
+ inTransaction = true;
106
106
  try {
107
107
  const result = fn();
108
108
  native.exec('COMMIT');
@@ -1,6 +1,6 @@
1
1
  import type { Db } from './connection.js';
2
- import { schemaSql } from './schema.js';
3
- const CURRENT_SCHEMA_VERSION = 10;
2
+ import { schemaIndexesSql, schemaTablesSql } from './schema.js';
3
+ const CURRENT_SCHEMA_VERSION = 11;
4
4
  const columns: Record<string, Array<{ name: string; ddl: string }>> = {
5
5
  handler_methods: [
6
6
  { name: 'decorator_resolution_json', ddl: "ALTER TABLE handler_methods ADD COLUMN decorator_resolution_json TEXT NOT NULL DEFAULT '{}'" },
@@ -57,6 +57,7 @@ const columns: Record<string, Array<{ name: string; ddl: string }>> = {
57
57
  ],
58
58
  index_runs: [
59
59
  { name: 'error_message', ddl: 'ALTER TABLE index_runs ADD COLUMN error_message TEXT' },
60
+ { name: 'owner_pid', ddl: 'ALTER TABLE index_runs ADD COLUMN owner_pid INTEGER' },
60
61
  ],
61
62
  };
62
63
  function hasColumn(db: Db, table: string, column: string): boolean {
@@ -81,8 +82,9 @@ export function migrate(db: Db): void {
81
82
  db.transaction(() => {
82
83
  const version = userVersion(db);
83
84
  if (version > CURRENT_SCHEMA_VERSION) throw new Error(`Unsupported future service-flow schema version ${version}`);
84
- db.exec(schemaSql);
85
+ db.exec(schemaTablesSql);
85
86
  addMissingColumns(db);
87
+ db.exec(schemaIndexesSql);
86
88
  normalizeLegacyStatus(db);
87
89
  const violations = db.pragma('foreign_key_check');
88
90
  if (violations.length > 0) throw new Error('SQLite foreign_key_check failed during migration');