@saptools/service-flow 0.1.68 → 0.1.69

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 (56) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +11 -5
  3. package/TECHNICAL-NOTE.md +9 -0
  4. package/dist/{chunk-AEM4JY22.js → chunk-3N3B5KHV.js} +6983 -5500
  5. package/dist/chunk-3N3B5KHV.js.map +1 -0
  6. package/dist/cli.js +317 -105
  7. package/dist/cli.js.map +1 -1
  8. package/dist/index.d.ts +7 -1
  9. package/dist/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/cli/001-index-summary.ts +22 -0
  12. package/src/cli.ts +151 -87
  13. package/src/db/000-call-fact-repository.ts +45 -19
  14. package/src/db/003-current-fact-semantics.ts +1 -0
  15. package/src/db/004-package-target-invalidation.ts +10 -0
  16. package/src/db/006-relative-symbol-resolution.ts +28 -7
  17. package/src/db/008-relative-fact-semantics.ts +6 -3
  18. package/src/db/009-binding-fact-semantics.ts +5 -0
  19. package/src/db/012-binding-reference-proof.ts +4 -0
  20. package/src/db/013-index-publication-failure.ts +91 -0
  21. package/src/db/014-binding-helper-provenance.ts +17 -0
  22. package/src/db/repositories.ts +22 -5
  23. package/src/indexer/cds-extension-resolver.ts +27 -3
  24. package/src/indexer/repository-indexer.ts +66 -5
  25. package/src/indexer/workspace-indexer.ts +141 -29
  26. package/src/linker/004-event-subscription-handler-linker.ts +32 -11
  27. package/src/linker/006-event-template-link.ts +72 -0
  28. package/src/linker/007-call-edge-insertion.ts +568 -0
  29. package/src/linker/cross-repo-linker.ts +2 -165
  30. package/src/parsers/000-direct-query-execution.ts +11 -0
  31. package/src/parsers/006-binding-identity.ts +6 -1
  32. package/src/parsers/014-service-binding-helper-flow.ts +70 -4
  33. package/src/parsers/021-binding-visibility.ts +17 -1
  34. package/src/parsers/022-outbound-expression-analysis.ts +700 -0
  35. package/src/parsers/023-outbound-call-classifier.ts +692 -0
  36. package/src/parsers/outbound-call-parser.ts +146 -509
  37. package/src/parsers/symbol-parser.ts +37 -5
  38. package/src/trace/007-implementation-start-diagnostic.ts +1 -0
  39. package/src/trace/011-event-subscriber-traversal.ts +100 -8
  40. package/src/trace/013-trace-root-scopes.ts +2 -3
  41. package/src/trace/014-compact-contract.ts +6 -0
  42. package/src/trace/015-trace-edge-recorder.ts +61 -4
  43. package/src/trace/016-compact-projector.ts +2 -3
  44. package/src/trace/019-trace-edge-semantics.ts +6 -10
  45. package/src/trace/020-compact-field-projection.ts +74 -1
  46. package/src/trace/021-compact-decision-normalization.ts +75 -9
  47. package/src/trace/024-compact-observation-decision.ts +7 -2
  48. package/src/trace/026-trace-start-scope.ts +1 -0
  49. package/src/trace/027-trace-scope-execution.ts +33 -33
  50. package/src/trace/030-event-runtime-resolution.ts +151 -0
  51. package/src/trace/031-local-call-expansion.ts +37 -0
  52. package/src/trace/implementation-hints.ts +9 -6
  53. package/src/trace/selectors.ts +1 -0
  54. package/src/types.ts +2 -1
  55. package/src/version.ts +1 -1
  56. package/dist/chunk-AEM4JY22.js.map +0 -1
@@ -1,5 +1,7 @@
1
1
  import { posix } from 'node:path';
2
2
  import type { SymbolCallFact } from '../types.js';
3
+ import { packageModuleRequest } from
4
+ '../parsers/002-symbol-import-bindings.js';
3
5
  import type { Db } from './connection.js';
4
6
 
5
7
  export interface RelativeSymbolCallResolution {
@@ -33,6 +35,7 @@ interface MappingTarget {
33
35
  interface MappedExecutableRows {
34
36
  rows: TargetRow[];
35
37
  ambiguous: boolean;
38
+ packageTargetUnsupported: boolean;
36
39
  }
37
40
 
38
41
  const stripExtension = (value: string): string =>
@@ -138,8 +141,12 @@ function handlerMemberPubliclyCarried(
138
141
  ): boolean {
139
142
  if (row.exported) return true;
140
143
  const evidence = parsedRecord(row.evidenceJson);
141
- return evidence?.source === 'exported_class_instance_member'
142
- && evidence.exportedClass === binding.importedName;
144
+ if (evidence?.source !== 'exported_class_instance_member') return false;
145
+ const exportKind = evidence.exportedClassExportKind;
146
+ if (exportKind !== 'default' && exportKind !== 'named') return false;
147
+ return binding.bindingKind === 'esm_default'
148
+ ? exportKind === 'default'
149
+ : evidence.exportedClass === binding.importedName;
143
150
  }
144
151
 
145
152
  function requiresPublicClassMember(
@@ -287,9 +294,12 @@ function mappingTargetScope(
287
294
  ): RelativeModuleScope | undefined {
288
295
  const target = mappingTarget(row);
289
296
  if (!target) return undefined;
290
- return target.specifier
291
- ? relativeModuleScope(db, repoId, row.sourceFile, target.specifier)
292
- : { paths: new Set([stripExtension(row.sourceFile)]), ambiguous: false };
297
+ if (!target.specifier)
298
+ return {
299
+ paths: new Set([stripExtension(row.sourceFile)]), ambiguous: false,
300
+ };
301
+ if (packageModuleRequest(target.specifier)) return undefined;
302
+ return relativeModuleScope(db, repoId, row.sourceFile, target.specifier);
293
303
  }
294
304
 
295
305
  function mappedExecutableRows(
@@ -298,18 +308,24 @@ function mappedExecutableRows(
298
308
  rows: readonly TargetRow[],
299
309
  aliases: readonly TargetRow[],
300
310
  ): MappedExecutableRows {
311
+ const packageTargetUnsupported = aliases.some((alias) => {
312
+ const target = mappingTarget(alias);
313
+ return Boolean(target?.specifier && packageModuleRequest(target.specifier));
314
+ });
301
315
  const scopes = aliases.flatMap((alias) => {
302
316
  const scope = mappingTargetScope(db, repoId, alias);
303
317
  return scope ? [scope] : [];
304
318
  });
305
319
  if (scopes.some((scope) => scope.ambiguous))
306
- return { rows: [], ambiguous: true };
320
+ return { rows: [], ambiguous: true, packageTargetUnsupported };
307
321
  const mapped = scopes.flatMap((scope) => {
308
322
  return rows.filter((row) =>
309
323
  row.kind !== 'object_alias' && bodyEligible(row)
310
324
  && scope.paths.has(stripExtension(row.sourceFile)));
311
325
  });
312
- return { rows: uniqueRows(mapped), ambiguous: false };
326
+ return {
327
+ rows: uniqueRows(mapped), ambiguous: false, packageTargetUnsupported,
328
+ };
313
329
  }
314
330
 
315
331
  function proxyResolution(
@@ -326,6 +342,11 @@ function proxyResolution(
326
342
  'relative_import_module_resolution_ambiguous',
327
343
  rows.length, 0, false,
328
344
  );
345
+ if (mapped.packageTargetUnsupported) return unresolved(
346
+ 'proxy_member_exported_object_map',
347
+ 'relative_import_proxy_alias_targets_package_unsupported',
348
+ rows.length,
349
+ );
329
350
  const direct = scoped.filter((row) =>
330
351
  row.kind !== 'object_alias' && row.exported && bodyEligible(row));
331
352
  const eligible = uniqueRows([
@@ -81,9 +81,12 @@ function derivedRelativeProvenance(
81
81
  targetName: string,
82
82
  ): boolean {
83
83
  if (!memberReference(binding)) return false;
84
- if (evidence.relation === 'class_instance_method')
85
- return targetName === binding.requestedPublicName
86
- && evidence.methodName === binding.referencedMemberName;
84
+ if (evidence.relation === 'class_instance_method') {
85
+ if (evidence.methodName !== binding.referencedMemberName) return false;
86
+ return binding.bindingKind === 'esm_default'
87
+ ? targetName === `${binding.localName}.${binding.referencedMemberName}`
88
+ : targetName === binding.requestedPublicName;
89
+ }
87
90
  if (evidence.relation !== 'relative_import_proxy_member') return false;
88
91
  return targetName === binding.referencedMemberName
89
92
  && typeof evidence.proxyVariableName === 'string';
@@ -9,6 +9,9 @@ import {
9
9
  validBindingLexicalScope,
10
10
  type BindingProofTarget,
11
11
  } from './012-binding-reference-proof.js';
12
+ import {
13
+ hasSingleHopHelperReturn,
14
+ } from './014-binding-helper-provenance.js';
12
15
 
13
16
  export interface BindingFactCategoryCount {
14
17
  category: string;
@@ -114,6 +117,7 @@ function bindingTargets(
114
117
  binding.binding_site_start_offset startOffset,
115
118
  binding.binding_site_end_offset endOffset,
116
119
  binding.owner_resolution ownerResolution,
120
+ binding.helper_chain_json helperChainJson,
117
121
  owner.start_offset ownerStartOffset,owner.end_offset ownerEndOffset
118
122
  FROM service_bindings binding
119
123
  JOIN repositories repo ON repo.id=binding.repo_id
@@ -140,6 +144,7 @@ function bindingTarget(
140
144
  ownerResolution: row.ownerResolution,
141
145
  ownerStartOffset: row.ownerStartOffset,
142
146
  ownerEndOffset: row.ownerEndOffset,
147
+ singleHopHelperReturn: hasSingleHopHelperReturn(row.helperChainJson),
143
148
  }];
144
149
  }
145
150
 
@@ -23,6 +23,7 @@ export interface BindingProofTarget {
23
23
  ownerResolution: string;
24
24
  ownerStartOffset: number | null;
25
25
  ownerEndOffset: number | null;
26
+ singleHopHelperReturn: boolean;
26
27
  }
27
28
 
28
29
  interface ResolvedBindingProof {
@@ -45,6 +46,7 @@ const strategies = new Set([
45
46
  'lexical_declaration',
46
47
  'lexical_alias_declaration',
47
48
  'deterministic_reaching_assignment',
49
+ 'single_hop_helper_return',
48
50
  ]);
49
51
 
50
52
  function record(value: unknown): Record<string, unknown> | undefined {
@@ -242,6 +244,8 @@ function targetMatches(
242
244
  target: BindingProofTarget | undefined,
243
245
  ): boolean {
244
246
  if (!target || call.bindingId !== target.id) return false;
247
+ if (reference.resolutionStrategy === 'single_hop_helper_return'
248
+ && !target.singleHopHelperReturn) return false;
245
249
  return all([
246
250
  target.repoId === call.repoId,
247
251
  target.sourceFile === call.sourceFile,
@@ -0,0 +1,91 @@
1
+ import type { Db } from './connection.js';
2
+
3
+ export type PreparedRepositoryFactKind =
4
+ | 'outbound_call'
5
+ | 'service_binding'
6
+ | 'symbol_call';
7
+
8
+ export type PreparedSnapshotFailureCode =
9
+ | 'binding_lexical_proof_invalid'
10
+ | 'binding_owner_mismatch'
11
+ | 'binding_reference_mismatch'
12
+ | 'binding_reference_missing'
13
+ | 'binding_site_missing'
14
+ | 'duplicate_service_binding_site'
15
+ | 'outbound_owner_mismatch'
16
+ | 'package_import_provenance_missing'
17
+ | 'symbol_call_owner_mismatch';
18
+
19
+ export interface PreparedSnapshotFailureSite {
20
+ factKind: PreparedRepositoryFactKind;
21
+ sourceFile?: string;
22
+ sourceLine?: number;
23
+ callSiteStartOffset?: number;
24
+ callSiteEndOffset?: number;
25
+ }
26
+
27
+ export class PreparedRepositorySnapshotError extends Error {
28
+ readonly failureCode: PreparedSnapshotFailureCode;
29
+ readonly site: PreparedSnapshotFailureSite;
30
+
31
+ constructor(
32
+ failureCode: PreparedSnapshotFailureCode,
33
+ site: PreparedSnapshotFailureSite,
34
+ ) {
35
+ super(`invalid_prepared_repository_snapshot:${failureCode}`);
36
+ this.name = 'PreparedRepositorySnapshotError';
37
+ this.failureCode = failureCode;
38
+ this.site = site;
39
+ }
40
+ }
41
+
42
+ interface PreparedCallSite {
43
+ sourceFile: string;
44
+ sourceLine: number;
45
+ callSiteStartOffset?: number;
46
+ callSiteEndOffset?: number;
47
+ }
48
+
49
+ export function preparedCallSnapshotError(
50
+ failureCode: PreparedSnapshotFailureCode,
51
+ factKind: 'outbound_call' | 'symbol_call',
52
+ call: PreparedCallSite,
53
+ ): PreparedRepositorySnapshotError {
54
+ return new PreparedRepositorySnapshotError(failureCode, {
55
+ factKind,
56
+ sourceFile: call.sourceFile,
57
+ sourceLine: call.sourceLine,
58
+ callSiteStartOffset: call.callSiteStartOffset,
59
+ callSiteEndOffset: call.callSiteEndOffset,
60
+ });
61
+ }
62
+
63
+ export function isPreparedRepositorySnapshotError(
64
+ error: unknown,
65
+ ): error is PreparedRepositorySnapshotError {
66
+ return error instanceof PreparedRepositorySnapshotError;
67
+ }
68
+
69
+ export function recordPreparedSnapshotFailure(
70
+ db: Db,
71
+ repoId: number,
72
+ error: PreparedRepositorySnapshotError,
73
+ ): void {
74
+ db.prepare(`UPDATE repositories SET index_status='failed',
75
+ error_count=1 WHERE id=?`).run(repoId);
76
+ db.prepare(`DELETE FROM diagnostics WHERE repo_id=? AND (
77
+ code IN ('index_failed_snapshot_preserved','source_read_failed')
78
+ OR code GLOB 'invalid_prepared_repository_snapshot:*'
79
+ )`).run(repoId);
80
+ db.prepare(`INSERT INTO diagnostics(
81
+ repo_id,severity,code,message,source_file,source_line
82
+ ) VALUES(?,?,?,?,?,?)`).run(
83
+ repoId,
84
+ 'error',
85
+ error.message,
86
+ 'Index publication failed before commit for this repository; previous facts and fingerprint were preserved. '
87
+ + `factKind=${error.site.factKind}`,
88
+ error.site.sourceFile,
89
+ error.site.sourceLine,
90
+ );
91
+ }
@@ -0,0 +1,17 @@
1
+ function record(value: unknown): value is Record<string, unknown> {
2
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
3
+ }
4
+
5
+ function parsed(value: string): unknown {
6
+ try {
7
+ return JSON.parse(value) as unknown;
8
+ } catch {
9
+ return undefined;
10
+ }
11
+ }
12
+
13
+ export function hasSingleHopHelperReturn(value: unknown): boolean {
14
+ const chain = typeof value === 'string' ? parsed(value) : value;
15
+ return Array.isArray(chain) && chain.some((step) =>
16
+ record(step) && step.bindingOrigin === 'single_hop_helper_return');
17
+ }
@@ -12,6 +12,10 @@ import {
12
12
  selectCallOwner,
13
13
  type OwnerCandidate,
14
14
  } from '../parsers/004-fact-identity.js';
15
+ import {
16
+ PreparedRepositorySnapshotError,
17
+ type PreparedSnapshotFailureCode,
18
+ } from './013-index-publication-failure.js';
15
19
  export interface RepoRow {
16
20
  id: number;
17
21
  name: string;
@@ -414,6 +418,19 @@ interface PersistedOwnerCandidate extends OwnerCandidate {
414
418
  id: number;
415
419
  }
416
420
 
421
+ function bindingSnapshotError(
422
+ failureCode: PreparedSnapshotFailureCode,
423
+ fact: ServiceBindingFact,
424
+ ): PreparedRepositorySnapshotError {
425
+ return new PreparedRepositorySnapshotError(failureCode, {
426
+ factKind: 'service_binding',
427
+ sourceFile: fact.sourceFile,
428
+ sourceLine: fact.sourceLine,
429
+ callSiteStartOffset: fact.bindingSiteStartOffset,
430
+ callSiteEndOffset: fact.bindingSiteEndOffset,
431
+ });
432
+ }
433
+
417
434
  function persistedOwnerCandidates(
418
435
  db: Db,
419
436
  repoId: number,
@@ -454,16 +471,16 @@ function bindingOwnerId(
454
471
  );
455
472
  if (fact.ownerResolution === 'ownerless_file_scope') {
456
473
  if (selected.status !== 'none')
457
- throw new Error('invalid_prepared_repository_snapshot:binding_owner_mismatch');
474
+ throw bindingSnapshotError('binding_owner_mismatch', fact);
458
475
  return null;
459
476
  }
460
477
  if (fact.ownerResolution !== 'owned_exact'
461
478
  || selected.status !== 'resolved'
462
479
  || selected.owner?.qualifiedName !== fact.sourceSymbolQualifiedName)
463
- throw new Error('invalid_prepared_repository_snapshot:binding_owner_mismatch');
480
+ throw bindingSnapshotError('binding_owner_mismatch', fact);
464
481
  const owner = selected.owner;
465
482
  if (!owner)
466
- throw new Error('invalid_prepared_repository_snapshot:binding_owner_mismatch');
483
+ throw bindingSnapshotError('binding_owner_mismatch', fact);
467
484
  return owner.id;
468
485
  }
469
486
 
@@ -474,13 +491,13 @@ function assertUniquePreparedBindingSites(
474
491
  for (const row of rows) {
475
492
  if (row.bindingSiteStartOffset === undefined
476
493
  || row.bindingSiteEndOffset === undefined)
477
- throw new Error('invalid_prepared_repository_snapshot:binding_site_missing');
494
+ throw bindingSnapshotError('binding_site_missing', row);
478
495
  const key = [
479
496
  row.sourceFile, row.variableName,
480
497
  row.bindingSiteStartOffset, row.bindingSiteEndOffset,
481
498
  ].join('\u0000');
482
499
  if (seen.has(key))
483
- throw new Error('invalid_prepared_repository_snapshot:duplicate_service_binding_site');
500
+ throw bindingSnapshotError('duplicate_service_binding_site', row);
484
501
  seen.add(key);
485
502
  }
486
503
  }
@@ -15,9 +15,12 @@ interface BaseRow { id: number; repoId: number }
15
15
  interface DesiredOperation { id: number; operationType: string; operationName: string; operationPath: string; paramsJson: string; returnType: string | null; sourceFile: string; sourceLine: number }
16
16
  interface ExistingOperation extends DesiredOperation { baseOperationId: number | null }
17
17
 
18
- export function materializeCdsExtensionOperations(db: Db, workspaceId: number): void {
19
- const extensions = db.prepare(`SELECT s.id,r.id repoId,s.service_name serviceName,s.qualified_name qualifiedName,s.source_file sourceFile,s.extension_module_specifier moduleSpecifier,s.extension_imported_symbol importedSymbol,s.extension_import_kind importKind
20
- FROM cds_services s JOIN repositories r ON r.id=s.repo_id WHERE r.workspace_id=? AND s.is_extend=1`).all(workspaceId) as unknown as ExtensionRow[];
18
+ export function materializeCdsExtensionOperations(
19
+ db: Db,
20
+ workspaceId: number,
21
+ excludedRepoIds: ReadonlySet<number> = new Set(),
22
+ ): void {
23
+ const extensions = extensionRows(db, workspaceId, excludedRepoIds);
21
24
  db.transaction(() => {
22
25
  const changedRepos = new Set<number>();
23
26
  for (const extension of extensions) {
@@ -27,6 +30,27 @@ export function materializeCdsExtensionOperations(db: Db, workspaceId: number):
27
30
  });
28
31
  }
29
32
 
33
+ function extensionRows(
34
+ db: Db,
35
+ workspaceId: number,
36
+ excludedRepoIds: ReadonlySet<number>,
37
+ ): ExtensionRow[] {
38
+ const excluded = [...excludedRepoIds].sort((left, right) => left - right);
39
+ const exclusion = excluded.length > 0
40
+ ? ` AND r.id NOT IN (${excluded.map(() => '?').join(',')})`
41
+ : '';
42
+ return db.prepare(`SELECT s.id,r.id repoId,s.service_name serviceName,
43
+ s.qualified_name qualifiedName,s.source_file sourceFile,
44
+ s.extension_module_specifier moduleSpecifier,
45
+ s.extension_imported_symbol importedSymbol,
46
+ s.extension_import_kind importKind
47
+ FROM cds_services s JOIN repositories r ON r.id=s.repo_id
48
+ WHERE r.workspace_id=? AND s.is_extend=1${exclusion}
49
+ ORDER BY r.id,s.id`).all(
50
+ workspaceId, ...excluded,
51
+ ) as unknown as ExtensionRow[];
52
+ }
53
+
30
54
  function reconcileExtension(db: Db, workspaceId: number, extension: ExtensionRow): boolean {
31
55
  const bases = resolveBase(db, workspaceId, extension);
32
56
  const status = bases.length === 1 ? 'resolved' : bases.length > 1 ? 'ambiguous' : 'unresolved';
@@ -45,6 +45,10 @@ import {
45
45
  invalidatePackageTargetFacts,
46
46
  type PackageInvalidationBatch,
47
47
  } from '../db/004-package-target-invalidation.js';
48
+ import {
49
+ isPreparedRepositorySnapshotError,
50
+ recordPreparedSnapshotFailure,
51
+ } from '../db/013-index-publication-failure.js';
48
52
  import {
49
53
  loadRepositorySourceContext,
50
54
  type RepositorySourceContext,
@@ -82,12 +86,21 @@ export async function indexRepository(
82
86
  ): Promise<IndexRepoResult> {
83
87
  try {
84
88
  const prepared = await prepareRepositoryIndex(repo, force, instrumentation);
85
- if (!prepared.skipped) db.transaction(() => {
89
+ if (prepared.skipped)
90
+ return { fileCount: 0, diagnosticCount: 0, skipped: true };
91
+ const outcome = db.transaction(() => {
86
92
  const batch = createPackageInvalidationBatch([prepared.repo.id]);
87
- publishPreparedRepositoryIndex(db, prepared, batch);
88
- finalizePackageTargetInvalidations(db, batch);
93
+ const published = publishOneRepository(db, prepared, batch);
94
+ if (published.ok) finalizePackageTargetInvalidations(db, batch);
95
+ return published;
89
96
  });
90
- return { fileCount: prepared.fileCount, diagnosticCount: prepared.diagnosticCount, skipped: prepared.skipped };
97
+ return outcome.ok
98
+ ? {
99
+ fileCount: prepared.fileCount,
100
+ diagnosticCount: prepared.diagnosticCount,
101
+ skipped: false,
102
+ }
103
+ : { fileCount: 0, diagnosticCount: 1, skipped: false };
91
104
  } catch (error) {
92
105
  recordIndexFailure(db, repo.id, error);
93
106
  return { fileCount: 0, diagnosticCount: 1, skipped: false };
@@ -172,10 +185,58 @@ export function publishPreparedRepositoryIndex(
172
185
  insertCalls(db, repoId, prepared.parsed.calls);
173
186
  db.prepare("UPDATE repositories SET last_indexed_at=?, index_status='indexed', error_count=0, fingerprint=?, fact_generation=COALESCE(fact_generation,0)+1, graph_stale_reason='facts_changed', graph_stale_at=?, fact_analyzer_version=? WHERE id=?").run(now, prepared.fingerprint, now, ANALYZER_VERSION, repoId);
174
187
  }
188
+
189
+ export type RepositoryPublicationOutcome =
190
+ | { ok: true }
191
+ | { ok: false; error: unknown };
192
+
193
+ export function publishOneRepository(
194
+ db: Db,
195
+ prepared: PreparedRepositoryIndex,
196
+ invalidations: PackageInvalidationBatch,
197
+ ): RepositoryPublicationOutcome {
198
+ try {
199
+ db.transaction(() => withPublicationSavepoint(
200
+ db,
201
+ prepared.repo.id,
202
+ () => publishPreparedRepositoryIndex(db, prepared, invalidations),
203
+ ));
204
+ return { ok: true };
205
+ } catch (error) {
206
+ recordIndexFailure(db, prepared.repo.id, error);
207
+ return { ok: false, error };
208
+ }
209
+ }
210
+
211
+ function withPublicationSavepoint<T>(
212
+ db: Db,
213
+ repoId: number,
214
+ publish: () => T,
215
+ ): T {
216
+ const name = `service_flow_repository_${repoId}`;
217
+ db.exec(`SAVEPOINT ${name}`);
218
+ try {
219
+ const result = publish();
220
+ db.exec(`RELEASE SAVEPOINT ${name}`);
221
+ return result;
222
+ } catch (error) {
223
+ db.exec(`ROLLBACK TO SAVEPOINT ${name}`);
224
+ db.exec(`RELEASE SAVEPOINT ${name}`);
225
+ throw error;
226
+ }
227
+ }
228
+
175
229
  export function recordIndexFailure(db: Db, repoId: number, error: unknown): void {
230
+ if (isPreparedRepositorySnapshotError(error)) {
231
+ recordPreparedSnapshotFailure(db, repoId, error);
232
+ return;
233
+ }
176
234
  const message = errorMessage(error);
177
235
  db.prepare("UPDATE repositories SET index_status='failed', error_count=1 WHERE id=?").run(repoId);
178
- db.prepare("DELETE FROM diagnostics WHERE repo_id=? AND code IN ('index_failed_snapshot_preserved','source_read_failed')").run(repoId);
236
+ db.prepare(`DELETE FROM diagnostics WHERE repo_id=? AND (
237
+ code IN ('index_failed_snapshot_preserved','source_read_failed')
238
+ OR code GLOB 'invalid_prepared_repository_snapshot:*'
239
+ )`).run(repoId);
179
240
  db.prepare('INSERT INTO diagnostics(repo_id,severity,code,message) VALUES(?,?,?,?)').run(repoId, 'error', 'source_read_failed', `Index failed before publication; previous facts and fingerprint were preserved. ${message}`);
180
241
  }
181
242
  async function parseAllSourceFacts(