@saptools/service-flow 0.1.51 → 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 (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +10 -5
  3. package/TECHNICAL-NOTE.md +8 -4
  4. package/dist/{chunk-YZJKE5UX.js → chunk-PTLDSHRC.js} +2412 -553
  5. package/dist/chunk-PTLDSHRC.js.map +1 -0
  6. package/dist/cli.js +280 -506
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +45 -7
  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 +75 -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/indexer/repository-indexer.ts +57 -29
  18. package/src/indexer/workspace-indexer.ts +84 -6
  19. package/src/linker/cross-repo-linker.ts +13 -1
  20. package/src/output/table-output.ts +29 -3
  21. package/src/parsers/cds-parser.ts +8 -2
  22. package/src/parsers/decorator-parser.ts +382 -48
  23. package/src/parsers/handler-registration-parser.ts +38 -21
  24. package/src/parsers/imported-wrapper-parser.ts +18 -5
  25. package/src/parsers/outbound-call-parser.ts +25 -8
  26. package/src/parsers/package-json-parser.ts +36 -11
  27. package/src/parsers/service-binding-parser-helpers.ts +8 -1
  28. package/src/parsers/service-binding-parser.ts +6 -3
  29. package/src/parsers/symbol-parser.ts +13 -3
  30. package/src/parsers/ts-project.ts +54 -0
  31. package/src/trace/000-dynamic-target-types.ts +84 -0
  32. package/src/trace/001-dynamic-identity.ts +280 -0
  33. package/src/trace/002-trace-diagnostics.ts +54 -0
  34. package/src/trace/003-dynamic-references.ts +82 -0
  35. package/src/trace/dynamic-targets.ts +459 -229
  36. package/src/trace/evidence.ts +305 -46
  37. package/src/trace/selectors.ts +483 -0
  38. package/src/trace/trace-engine.ts +90 -83
  39. package/src/types.ts +27 -1
  40. package/dist/chunk-YZJKE5UX.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;
@@ -138,6 +159,7 @@ interface ImplementationHint {
138
159
  type DynamicMode = 'strict' | 'candidates' | 'infer';
139
160
  interface TraceOptions {
140
161
  depth: number;
162
+ workspaceId?: number;
141
163
  vars?: Record<string, string>;
142
164
  includeExternal?: boolean;
143
165
  includeDb?: boolean;
@@ -165,17 +187,33 @@ interface TraceResult {
165
187
 
166
188
  declare function discoverRepositories(rootPath: string, ignore: readonly string[]): Promise<DiscoveredRepository[]>;
167
189
 
168
- 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
+ }
169
207
 
170
- declare function parseCdsFile(repoPath: string, filePath: string): Promise<CdsServiceFact[]>;
208
+ declare function parseCdsFile(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<CdsServiceFact[]>;
171
209
 
172
- declare function parseDecorators(repoPath: string, filePath: string): Promise<HandlerClassFact[]>;
210
+ declare function parseDecorators(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerClassFact[]>;
173
211
 
174
- declare function parseHandlerRegistrations(repoPath: string, filePath: string): Promise<HandlerRegistrationFact[]>;
212
+ declare function parseHandlerRegistrations(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<HandlerRegistrationFact[]>;
175
213
 
176
- declare function parseServiceBindings(repoPath: string, filePath: string): Promise<ServiceBindingFact[]>;
214
+ declare function parseServiceBindings(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<ServiceBindingFact[]>;
177
215
 
178
- declare function parseOutboundCalls(repoPath: string, filePath: string): Promise<OutboundCallFact[]>;
216
+ declare function parseOutboundCalls(repoPath: string, filePath: string, context?: RepositorySourceContext): Promise<OutboundCallFact[]>;
179
217
 
180
218
  declare function parseGeneratedConstants(repoPath: string, filePath: string): Promise<GeneratedConstantFact[]>;
181
219
 
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  substituteVariables,
19
19
  trace
20
- } from "./chunk-YZJKE5UX.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.51",
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,7 +21,10 @@ 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';
@@ -30,6 +32,7 @@ import { renderDoctorDiagnostics } from './output/doctor-output.js';
30
32
  import { renderMermaid } from './output/mermaid-output.js';
31
33
  import { VERSION } from './version.js';
32
34
  import type { DynamicMode } from './types.js';
35
+ import { cleanWorkspaceState } from './cli/000-clean.js';
33
36
  async function init(
34
37
  workspace: string,
35
38
  options: { db?: string; ignore?: string[] },
@@ -92,6 +95,30 @@ async function withReadOnlyWorkspace<T>(
92
95
  db.close();
93
96
  }
94
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
+ }
95
122
  export function createProgram(): Command {
96
123
  const program = new Command();
97
124
  program
@@ -177,7 +204,7 @@ export function createProgram(): Command {
177
204
  dynamicMode: string;
178
205
  maxDynamicCandidates: string;
179
206
  }) =>
180
- void withReadOnlyWorkspace(opts.workspace, (db) => {
207
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
181
208
  const result = trace(
182
209
  db,
183
210
  {
@@ -189,6 +216,7 @@ export function createProgram(): Command {
189
216
  },
190
217
  {
191
218
  depth: Number(opts.depth),
219
+ workspaceId,
192
220
  vars: parseVars(opts.var),
193
221
  includeExternal: Boolean(opts.includeExternal),
194
222
  includeDb: Boolean(opts.includeDb),
@@ -214,10 +242,10 @@ export function createProgram(): Command {
214
242
  .option('--workspace <path>')
215
243
  .action(
216
244
  (opts: { workspace?: string }) =>
217
- void withReadOnlyWorkspace(opts.workspace, (db) =>
245
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) =>
218
246
  process.stdout.write(
219
247
  renderJson(
220
- listRepositories(db).map((r) => ({
248
+ listRepositories(db, workspaceId).map((r) => ({
221
249
  name: r.name,
222
250
  kind: r.kind,
223
251
  packageName: r.package_name,
@@ -232,17 +260,20 @@ export function createProgram(): Command {
232
260
  .option('--repo <name>')
233
261
  .action(
234
262
  (opts: { workspace?: string; repo?: string }) =>
235
- void withReadOnlyWorkspace(opts.workspace, (db) => {
236
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
237
- if (opts.repo && !repo) {
238
- 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]));
239
269
  return;
240
270
  }
271
+ const repo = selection.repo;
241
272
  const rows = db
242
273
  .prepare(
243
- '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',
244
275
  )
245
- .all(repo?.id, repo?.id);
276
+ .all(workspaceId, repo?.id, repo?.id);
246
277
  process.stdout.write(renderJson(rows));
247
278
  }).catch(fail),
248
279
  );
@@ -253,17 +284,20 @@ export function createProgram(): Command {
253
284
  .option('--service <path>')
254
285
  .action(
255
286
  (opts: { workspace?: string; repo?: string; service?: string }) =>
256
- void withReadOnlyWorkspace(opts.workspace, (db) => {
257
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
258
- if (opts.repo && !repo) {
259
- 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]));
260
293
  return;
261
294
  }
295
+ const repo = selection.repo;
262
296
  const rows = db
263
297
  .prepare(
264
- '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=?)',
265
299
  )
266
- .all(repo?.id, repo?.id, opts.service, opts.service);
300
+ .all(workspaceId, repo?.id, repo?.id, opts.service, opts.service);
267
301
  process.stdout.write(renderJson(rows));
268
302
  }).catch(fail),
269
303
  );
@@ -274,17 +308,21 @@ export function createProgram(): Command {
274
308
  .option('--operation <name>')
275
309
  .action(
276
310
  (opts: { workspace?: string; repo?: string; operation?: string }) =>
277
- void withReadOnlyWorkspace(opts.workspace, (db) => {
278
- const repo = opts.repo ? repoByName(db, opts.repo) : undefined;
279
- if (opts.repo && !repo) {
280
- 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]));
281
317
  return;
282
318
  }
319
+ const repo = selection.repo;
283
320
  const rows = db
284
321
  .prepare(
285
- '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 ?)',
286
323
  )
287
324
  .all(
325
+ workspaceId,
288
326
  repo?.id,
289
327
  repo?.id,
290
328
  opts.operation,
@@ -322,7 +360,7 @@ export function createProgram(): Command {
322
360
  dynamicMode: string;
323
361
  maxDynamicCandidates: string;
324
362
  }) =>
325
- void withReadOnlyWorkspace(opts.workspace, (db) => {
363
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
326
364
  const result = trace(
327
365
  db,
328
366
  {
@@ -333,6 +371,7 @@ export function createProgram(): Command {
333
371
  },
334
372
  {
335
373
  depth: 100,
374
+ workspaceId,
336
375
  includeAsync: true,
337
376
  includeDb: true,
338
377
  includeExternal: true,
@@ -357,11 +396,12 @@ export function createProgram(): Command {
357
396
  .option('--workspace <path>')
358
397
  .action(
359
398
  (name: string, opts: { workspace?: string }) =>
360
- void withReadOnlyWorkspace(opts.workspace, (db) =>
361
- process.stdout.write(
362
- renderJson(repoByName(db, name) ?? { error: 'repo not found' }),
363
- ),
364
- ).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),
365
405
  );
366
406
  inspect
367
407
  .command('operation')
@@ -369,12 +409,12 @@ export function createProgram(): Command {
369
409
  .option('--workspace <path>')
370
410
  .action(
371
411
  (selector: string, opts: { workspace?: string }) =>
372
- void withReadOnlyWorkspace(opts.workspace, (db) => {
412
+ void withReadOnlyWorkspace(opts.workspace, (db, workspaceId) => {
373
413
  const rows = db
374
414
  .prepare(
375
- '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=?)',
376
416
  )
377
- .all(selector, selector);
417
+ .all(workspaceId, selector, selector);
378
418
  process.stdout.write(renderJson(rows));
379
419
  }).catch(fail),
380
420
  );
@@ -399,29 +439,7 @@ export function createProgram(): Command {
399
439
  (opts: { workspace?: string; dbOnly?: boolean }) =>
400
440
  void (async () => {
401
441
  const config = await loadWorkspaceConfig(opts.workspace);
402
- const dbDir = path.resolve(path.dirname(config.dbPath));
403
- const workspaceRoot = path.resolve(config.rootPath);
404
- await fs.rm(config.dbPath, { force: true });
405
- if (!opts.dbOnly) {
406
- const marker = path.join(dbDir, '.service-flow-state');
407
- const dangerous = new Set([
408
- path.parse(dbDir).root,
409
- '/tmp',
410
- process.env.HOME ? path.resolve(process.env.HOME) : '',
411
- workspaceRoot,
412
- ]);
413
- let ownsState: boolean;
414
- try {
415
- ownsState = (await fs.stat(marker)).isFile();
416
- } catch {
417
- ownsState = false;
418
- }
419
- if (!ownsState || dangerous.has(dbDir))
420
- throw new Error(
421
- `Refusing to recursively delete unowned or dangerous state directory: ${dbDir}. Use --db-only to remove only the database file.`,
422
- );
423
- await fs.rm(dbDir, { recursive: true, force: true });
424
- }
442
+ await cleanWorkspaceState(config, Boolean(opts.dbOnly));
425
443
  process.stdout.write('Cleaned service-flow state\n');
426
444
  })().catch(fail),
427
445
  );
@@ -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');
@@ -3,6 +3,7 @@ import type {
3
3
  CdsRequire,
4
4
  CdsServiceFact,
5
5
  HandlerClassFact,
6
+ HandlerMethodFact,
6
7
  HandlerRegistrationFact,
7
8
  OutboundCallFact,
8
9
  ServiceBindingFact,
@@ -85,15 +86,29 @@ export function upsertRepository(
85
86
  .get(workspaceId, r.absolutePath)?.id,
86
87
  );
87
88
  }
88
- export function listRepositories(db: Db): RepoRow[] {
89
+ export function listRepositories(db: Db, workspaceId?: number): RepoRow[] {
89
90
  return db
90
- .prepare('SELECT * FROM repositories ORDER BY name')
91
- .all() as unknown as RepoRow[];
91
+ .prepare('SELECT * FROM repositories WHERE (? IS NULL OR workspace_id=?) ORDER BY name,absolute_path,id')
92
+ .all(workspaceId, workspaceId) as unknown as RepoRow[];
92
93
  }
93
- export function repoByName(db: Db, name: string): RepoRow | undefined {
94
+ export function repoByName(
95
+ db: Db,
96
+ name: string,
97
+ workspaceId?: number,
98
+ ): RepoRow | undefined {
99
+ const matches = reposByName(db, name, workspaceId);
100
+ return matches.length === 1 ? matches[0] : undefined;
101
+ }
102
+ export function reposByName(
103
+ db: Db,
104
+ name: string,
105
+ workspaceId?: number,
106
+ ): RepoRow[] {
94
107
  return db
95
- .prepare('SELECT * FROM repositories WHERE name=? OR package_name=?')
96
- .get(name, name) as RepoRow | undefined;
108
+ .prepare(`SELECT * FROM repositories
109
+ WHERE (? IS NULL OR workspace_id=?) AND (name=? OR package_name=?)
110
+ ORDER BY name,absolute_path,id`)
111
+ .all(workspaceId, workspaceId, name, name) as unknown as RepoRow[];
97
112
  }
98
113
  export function clearRepoFacts(db: Db, repoId: number): void {
99
114
  for (const t of [
@@ -188,21 +203,7 @@ export function insertHandler(
188
203
  repoId: number,
189
204
  h: HandlerClassFact,
190
205
  ): number {
191
- const sid = Number(
192
- db
193
- .prepare(
194
- 'INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line) VALUES(?,?,?,?,?,?,?) RETURNING id',
195
- )
196
- .get(
197
- repoId,
198
- 'class',
199
- h.className,
200
- h.className,
201
- 1,
202
- h.sourceLine,
203
- h.sourceLine,
204
- )?.id,
205
- );
206
+ const sid = insertHandlerClassSymbol(db, repoId, h);
206
207
  const hid = Number(
207
208
  db
208
209
  .prepare(
@@ -220,12 +221,109 @@ export function insertHandler(
220
221
  m.decoratorKind,
221
222
  m.decoratorValue,
222
223
  m.decoratorRawExpression,
223
- JSON.stringify(m.decoratorResolution),
224
+ JSON.stringify(canonicalHandlerMethodResolution(m)),
224
225
  m.sourceFile,
225
226
  m.sourceLine,
226
227
  );
228
+ insertHandlerIndexDiagnostic(db, repoId, h);
227
229
  return hid;
228
230
  }
231
+ function insertHandlerClassSymbol(
232
+ db: Db,
233
+ repoId: number,
234
+ h: HandlerClassFact,
235
+ ): number {
236
+ const classEvidence = {
237
+ hasHandlerDecorator: h.hasHandlerDecorator ?? false,
238
+ classDecoratorNames: h.classDecoratorNames ?? [],
239
+ observedDecoratorNames: h.observedDecoratorNames ?? [],
240
+ unsupportedDecoratorNames: h.unsupportedDecoratorNames ?? [],
241
+ unsupportedMethods: h.methods
242
+ .filter((method) => !handlerMethodIsExecutable(method))
243
+ .map((method) => ({
244
+ methodName: method.methodName,
245
+ decoratorKind: method.decoratorKind,
246
+ sourceFile: method.sourceFile,
247
+ sourceLine: method.sourceLine,
248
+ reason: method.decoratorResolution.unresolvedReason,
249
+ })),
250
+ };
251
+ return Number(
252
+ db
253
+ .prepare(
254
+ 'INSERT INTO symbols(repo_id,kind,name,qualified_name,exported,start_line,end_line,source_file,evidence_json) VALUES(?,?,?,?,?,?,?,?,?) RETURNING id',
255
+ )
256
+ .get(
257
+ repoId,
258
+ 'class',
259
+ h.className,
260
+ h.className,
261
+ 1,
262
+ h.sourceLine,
263
+ h.sourceLine,
264
+ h.sourceFile,
265
+ JSON.stringify(classEvidence),
266
+ )?.id,
267
+ );
268
+ }
269
+ function insertHandlerIndexDiagnostic(
270
+ db: Db,
271
+ repoId: number,
272
+ h: HandlerClassFact,
273
+ ): void {
274
+ if (!h.hasHandlerDecorator) return;
275
+ const hasExecutable = h.methods.some(handlerMethodIsExecutable);
276
+ const unsupported = h.methods.filter((method) =>
277
+ !handlerMethodIsExecutable(method));
278
+ if (hasExecutable && unsupported.length === 0) return;
279
+ const code = hasExecutable
280
+ ? 'handler_decorators_not_indexed'
281
+ : 'handler_methods_not_indexed';
282
+ const names = unsupported.map((method) => method.decoratorKind).sort();
283
+ const detail = names.length > 0
284
+ ? ` Unsupported decorators: ${[...new Set(names)].join(', ')}.`
285
+ : '';
286
+ db.prepare(
287
+ 'INSERT INTO diagnostics(repo_id,severity,code,message,source_file,source_line) VALUES(?,?,?,?,?,?)',
288
+ ).run(
289
+ repoId,
290
+ 'warning',
291
+ code,
292
+ hasExecutable
293
+ ? `Handler class ${h.className} contains methods that were not indexed.${detail}`
294
+ : `Handler class ${h.className} has no indexed executable methods; use a supported CAP handler decorator and re-index.${detail}`,
295
+ h.sourceFile,
296
+ h.sourceLine,
297
+ );
298
+ }
299
+ export function canonicalHandlerMethodResolution(
300
+ method: HandlerMethodFact,
301
+ ): HandlerMethodFact['decoratorResolution'] {
302
+ const handlerKind = method.handlerKind
303
+ ?? method.decoratorResolution.handlerKind
304
+ ?? legacyHandlerKind(method.decoratorKind);
305
+ const executable = method.executable
306
+ ?? method.decoratorResolution.executable
307
+ ?? (handlerKind === 'operation' || handlerKind === 'event'
308
+ || handlerKind === 'entity_lifecycle');
309
+ return {
310
+ ...method.decoratorResolution,
311
+ handlerKind,
312
+ executable,
313
+ lifecyclePhase: method.lifecyclePhase
314
+ ?? method.decoratorResolution.lifecyclePhase,
315
+ lifecycleEvent: method.lifecycleEvent
316
+ ?? method.decoratorResolution.lifecycleEvent,
317
+ };
318
+ }
319
+ export function handlerMethodIsExecutable(method: HandlerMethodFact): boolean {
320
+ return canonicalHandlerMethodResolution(method).executable === true;
321
+ }
322
+ function legacyHandlerKind(kind: string): HandlerMethodFact['handlerKind'] {
323
+ if (kind === 'Event') return 'event';
324
+ if (['Action', 'Func', 'On'].includes(kind)) return 'operation';
325
+ return 'unsupported_decorator';
326
+ }
229
327
  export function insertRegistrations(
230
328
  db: Db,
231
329
  repoId: number,