@topogram/cli 0.3.75 → 0.3.76

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topogram/cli",
3
- "version": "0.3.75",
3
+ "version": "0.3.76",
4
4
  "description": "Topogram CLI for checking Topogram workspaces and generating app bundles.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -637,6 +637,7 @@ function mappingSuggestionsForItem(item) {
637
637
  }
638
638
 
639
639
  export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact = null) {
640
+ const importedMaintainedDbSeams = [...(adoptionPlan?.imported_maintained_db_seams || [])];
640
641
  const items = (adoptionPlan?.items || []).map((item) => {
641
642
  const currentState = inferCurrentAdoptionState(item);
642
643
  const maintainedSeamCandidates = inferMaintainedSeamCandidates({
@@ -656,6 +657,9 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
656
657
  ui_impacts: [...(item.ui_impacts || [])],
657
658
  workflow_impacts: [...(item.workflow_impacts || [])]
658
659
  }, maintainedBoundaryArtifact);
660
+ const importedDbSeamCandidates = ["entity", "enum", "index", "relation", "db"].includes(item.kind) || item.track === "db"
661
+ ? importedMaintainedDbSeams
662
+ : [];
659
663
  return {
660
664
  id: adoptionItemKey(item),
661
665
  bundle: item.bundle,
@@ -691,11 +695,57 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
691
695
  projection_impacts: [...(item.projection_impacts || [])],
692
696
  ui_impacts: [...(item.ui_impacts || [])],
693
697
  workflow_impacts: [...(item.workflow_impacts || [])],
694
- maintained_seam_candidates: maintainedSeamCandidates,
698
+ maintained_seam_candidates: [...maintainedSeamCandidates, ...importedDbSeamCandidates],
695
699
  mapping_suggestions: mappingSuggestionsForItem(item),
696
700
  available_actions: ADOPTION_STATE_VOCABULARY
697
701
  };
698
702
  });
703
+ const importedDbSeamSurfaces = importedMaintainedDbSeams.map((seam) => ({
704
+ id: seam.id_hint || seam.seam_id,
705
+ bundle: "database",
706
+ item: seam.id_hint || seam.seam_id,
707
+ kind: seam.kind || "maintained_db_migration_seam",
708
+ track: "db",
709
+ source_kind: seam.source_kind || "migration_strategy_inference",
710
+ source_path: "candidates/app/db/candidates.json",
711
+ canonical_rel_path: null,
712
+ related_shapes: [],
713
+ review_boundary: {
714
+ automation_class: "review_required",
715
+ reasons: [
716
+ "manual_project_config_review",
717
+ "proposal_only_maintained_db_migration_seam"
718
+ ]
719
+ },
720
+ current_state: "stage",
721
+ recommended_state: "customize",
722
+ supported_states: ADOPTION_STATE_VOCABULARY,
723
+ human_review_required: true,
724
+ provenance: {
725
+ bundle: "database",
726
+ source_path: "candidates/app/db/candidates.json",
727
+ canonical_rel_path: null
728
+ },
729
+ requirements: {
730
+ related_docs: [],
731
+ related_capabilities: [],
732
+ related_shapes: [],
733
+ related_rules: [],
734
+ related_workflows: [],
735
+ blocking_dependencies: []
736
+ },
737
+ projection_impacts: [],
738
+ ui_impacts: [],
739
+ workflow_impacts: [],
740
+ maintained_seam_candidates: [seam],
741
+ mapping_suggestions: [{
742
+ type: "manual_project_config_review",
743
+ id: seam.id_hint || seam.seam_id,
744
+ reason: "Review the inferred database migration strategy before editing topogram.project.json."
745
+ }],
746
+ available_actions: ADOPTION_STATE_VOCABULARY
747
+ }));
748
+ const proposalSurfaces = [...items, ...importedDbSeamSurfaces];
699
749
 
700
750
  return {
701
751
  type: "agent_adoption_plan",
@@ -708,10 +758,11 @@ export function buildAgentAdoptionPlan(adoptionPlan, maintainedBoundaryArtifact
708
758
  non_canonical_adoption_state: "stage"
709
759
  },
710
760
  approved_review_groups: [...new Set(adoptionPlan?.approved_review_groups || [])].sort(),
711
- imported_proposal_surfaces: items,
712
- staged_items: items.filter((item) => item.current_state === "stage").map((item) => item.id),
713
- accepted_items: items.filter((item) => item.current_state === "accept").map((item) => item.id),
714
- rejected_items: items.filter((item) => item.current_state === "reject").map((item) => item.id),
715
- requires_human_review: items.filter((item) => item.human_review_required).map((item) => item.id)
761
+ imported_maintained_db_seam_candidates: importedMaintainedDbSeams,
762
+ imported_proposal_surfaces: proposalSurfaces,
763
+ staged_items: proposalSurfaces.filter((item) => item.current_state === "stage").map((item) => item.id),
764
+ accepted_items: proposalSurfaces.filter((item) => item.current_state === "accept").map((item) => item.id),
765
+ rejected_items: proposalSurfaces.filter((item) => item.current_state === "reject").map((item) => item.id),
766
+ requires_human_review: proposalSurfaces.filter((item) => item.human_review_required).map((item) => item.id)
716
767
  };
717
768
  }
@@ -110,6 +110,7 @@ export function importCandidateCounts(summary) {
110
110
  return {
111
111
  dbEntities: candidates.db?.entities?.length || 0,
112
112
  dbEnums: candidates.db?.enums?.length || 0,
113
+ dbMaintainedSeams: candidates.db?.maintained_seams?.length || 0,
113
114
  apiCapabilities: candidates.api?.capabilities?.length || 0,
114
115
  apiRoutes: candidates.api?.routes?.length || 0,
115
116
  uiScreens: candidates.ui?.screens?.length || 0,
@@ -261,7 +261,8 @@ export function normalizeCandidatesForTrack(track, candidates) {
261
261
  entities: dedupeCandidateRecords(candidates.entities || [], idHint),
262
262
  enums: dedupeCandidateRecords(candidates.enums || [], idHint),
263
263
  relations: dedupeCandidateRecords(candidates.relations || [], idHint),
264
- indexes: dedupeCandidateRecords(candidates.indexes || [], idHint)
264
+ indexes: dedupeCandidateRecords(candidates.indexes || [], idHint),
265
+ maintained_seams: dedupeCandidateRecords(candidates.maintained_seams || [], idHint)
265
266
  };
266
267
  }
267
268
  if (track === "api") {
@@ -10,8 +10,11 @@ import { uiWidgetCandidates } from "./candidates.js";
10
10
  */
11
11
  export function reportMarkdown(track, candidates) {
12
12
  if (track === "db") {
13
+ const seamLines = (candidates.maintained_seams || []).map((/** @type {any} */ seam) =>
14
+ `- \`${seam.id_hint}\` tool \`${seam.tool}\` confidence ${seam.confidence || "unknown"} schema \`${seam.schemaPath || "none"}\` migrations \`${seam.migrationsPath || "review-required"}\` missing decisions ${(seam.missing_decisions || []).length}`
15
+ );
13
16
  return ensureTrailingNewline(
14
- `# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n`
17
+ `# DB Import Report\n\n- Entities: ${candidates.entities.length}\n- Enums: ${candidates.enums.length}\n- Relations: ${candidates.relations.length}\n- Indexes: ${candidates.indexes.length}\n- Maintained DB migration seams: ${(candidates.maintained_seams || []).length}\n\n## Maintained DB Migration Seam Candidates\n\n${seamLines.length ? seamLines.join("\n") : "- none"}\n`
15
18
  );
16
19
  }
17
20
  if (track === "api") {
@@ -54,6 +57,6 @@ export function reportMarkdown(track, candidates) {
54
57
  */
55
58
  export function appReportMarkdown(candidates, tracks) {
56
59
  return ensureTrailingNewline(
57
- `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
60
+ `# App Import Report\n\nTracks: ${tracks.join(", ")}\n\n## DB\n\n- Entities: ${candidates.db?.entities?.length || 0}\n- Enums: ${candidates.db?.enums?.length || 0}\n- Relations: ${candidates.db?.relations?.length || 0}\n- Maintained DB migration seams: ${candidates.db?.maintained_seams?.length || 0}\n\n## API\n\n- Capabilities: ${candidates.api?.capabilities?.length || 0}\n- Routes: ${candidates.api?.routes?.length || 0}\n- Stacks: ${candidates.api?.stacks?.length ? candidates.api.stacks.join(", ") : "none"}\n\n## UI\n\n- Screens: ${candidates.ui?.screens?.length || 0}\n- Routes: ${candidates.ui?.routes?.length || 0}\n- Actions: ${candidates.ui?.actions?.length || 0}\n- Widgets: ${uiWidgetCandidates(candidates.ui).length}\n- Event payload shapes: ${candidates.ui?.shapes?.length || 0}\n- Stacks: ${candidates.ui?.stacks?.length ? candidates.ui.stacks.join(", ") : "none"}\n\n## CLI\n\n- Commands: ${candidates.cli?.commands?.length || 0}\n- Capabilities: ${candidates.cli?.capabilities?.length || 0}\n- CLI surfaces: ${candidates.cli?.surfaces?.length || 0}\n\n## Workflows\n\n- Workflows: ${candidates.workflows?.workflows?.length || 0}\n- States: ${candidates.workflows?.workflow_states?.length || 0}\n- Transitions: ${candidates.workflows?.workflow_transitions?.length || 0}\n\n## Verification\n\n- Verifications: ${candidates.verification?.verifications?.length || 0}\n- Scenarios: ${candidates.verification?.scenarios?.length || 0}\n- Frameworks: ${candidates.verification?.frameworks?.length ? candidates.verification.frameworks.join(", ") : "none"}\n- Scripts: ${candidates.verification?.scripts?.length || 0}\n`
58
61
  );
59
62
  }
@@ -96,7 +96,7 @@ function selectDetectionsForTrack(track, detections) {
96
96
  */
97
97
  function initialCandidatesForTrack(track) {
98
98
  if (track === "db") {
99
- return { entities: [], enums: [], relations: [], indexes: [] };
99
+ return { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
100
100
  }
101
101
  if (track === "api") {
102
102
  return { capabilities: [], routes: [], stacks: [] };
@@ -10,6 +10,7 @@ import {
10
10
  slugify,
11
11
  titleCase
12
12
  } from "../../core/shared.js";
13
+ import { inferDrizzleMaintainedDbSeams } from "./maintained-seams.js";
13
14
 
14
15
  function splitTopLevelEntries(block) {
15
16
  const entries = [];
@@ -165,21 +166,50 @@ function parseDrizzleTables(schemaText) {
165
166
  };
166
167
  }
167
168
 
169
+ function drizzleConfigFiles(context) {
170
+ return findImportFiles(context.paths, (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath)));
171
+ }
172
+
173
+ function configuredSchemaFiles(context, configFiles) {
174
+ const files = [];
175
+ for (const configFile of configFiles) {
176
+ const configText = context.helpers.readTextIfExists(configFile) || "";
177
+ for (const match of configText.matchAll(/\bschema\s*:\s*["'`]([^"'`*]+)["'`]/g)) {
178
+ const absoluteSchemaPath = path.resolve(path.dirname(configFile), match[1]);
179
+ if (context.helpers.readTextIfExists(absoluteSchemaPath) !== null) {
180
+ files.push(absoluteSchemaPath);
181
+ }
182
+ }
183
+ }
184
+ return files;
185
+ }
186
+
187
+ function findDrizzleSchemaFiles(context) {
188
+ const configFiles = drizzleConfigFiles(context);
189
+ const conventionalSchemaFiles = findImportFiles(context.paths, (filePath) =>
190
+ /(?:^|\/)(?:src\/db\/schema|src\/schema|db\/schema|schema)\.(ts|js|mjs|cjs)$/i.test(relativeTo(context.paths.workspaceRoot, filePath).replaceAll(path.sep, "/"))
191
+ );
192
+ return [...new Set([
193
+ ...configuredSchemaFiles(context, configFiles),
194
+ ...conventionalSchemaFiles
195
+ ])].sort();
196
+ }
197
+
168
198
  export const drizzleExtractor = {
169
199
  id: "db.drizzle",
170
200
  track: "db",
171
201
  detect(context) {
172
- const hasConfig = findImportFiles(context.paths, (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath))).length > 0;
173
- const hasSchema = findImportFiles(context.paths, (filePath) => /src\/schema\.(ts|js|mjs|cjs)$/i.test(filePath)).length > 0;
202
+ const hasConfig = drizzleConfigFiles(context).length > 0;
203
+ const hasSchema = findDrizzleSchemaFiles(context).length > 0;
174
204
  return {
175
205
  score: hasConfig || hasSchema ? 95 : 0,
176
206
  reasons: hasConfig || hasSchema ? ["Found Drizzle config/schema source"] : []
177
207
  };
178
208
  },
179
209
  extract(context) {
180
- const schemaFiles = findImportFiles(context.paths, (filePath) => /src\/schema\.(ts|js|mjs|cjs)$/i.test(filePath));
210
+ const schemaFiles = findDrizzleSchemaFiles(context);
181
211
  const findings = [];
182
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
212
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
183
213
  for (const filePath of schemaFiles) {
184
214
  const parsed = parseDrizzleTables(context.helpers.readTextIfExists(filePath) || "");
185
215
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -237,6 +267,7 @@ export const drizzleExtractor = {
237
267
  candidates.enums = dedupeCandidateRecords(candidates.enums, (record) => record.id_hint);
238
268
  candidates.relations = dedupeCandidateRecords(candidates.relations, (record) => record.id_hint);
239
269
  candidates.indexes = dedupeCandidateRecords(candidates.indexes, (record) => record.id_hint);
270
+ candidates.maintained_seams = inferDrizzleMaintainedDbSeams(context, schemaFiles);
240
271
  return { findings, candidates };
241
272
  }
242
273
  };
@@ -0,0 +1,208 @@
1
+ // @ts-check
2
+
3
+ import path from "node:path";
4
+
5
+ import { findImportFiles, makeCandidateRecord, relativeTo } from "../../core/shared.js";
6
+
7
+ /** @param {string} value @returns {string} */
8
+ function toPosix(value) {
9
+ return String(value || "").replaceAll(path.sep, "/");
10
+ }
11
+
12
+ /** @param {any} context @param {string} filePath @returns {string} */
13
+ function appRelativePath(context, filePath) {
14
+ return toPosix(relativeTo(context.paths.workspaceRoot, filePath));
15
+ }
16
+
17
+ /** @param {any} context @param {string} filePath @returns {string} */
18
+ function evidencePath(context, filePath) {
19
+ return toPosix(relativeTo(context.paths.repoRoot, filePath));
20
+ }
21
+
22
+ /** @param {string} relativePath @param {string[]} segments @returns {string|null} */
23
+ function prefixThroughSegments(relativePath, segments) {
24
+ const parts = toPosix(relativePath).split("/");
25
+ for (let index = 0; index <= parts.length - segments.length; index += 1) {
26
+ if (segments.every((segment, offset) => parts[index + offset] === segment)) {
27
+ return parts.slice(0, index + segments.length).join("/");
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** @param {string} relativePath @returns {string|null} */
34
+ function migrationDirectoryFromRelativePath(relativePath) {
35
+ return prefixThroughSegments(relativePath, ["migrations"]) ||
36
+ prefixThroughSegments(relativePath, ["migration"]);
37
+ }
38
+
39
+ /** @param {any} context @param {string[]} files @param {string[][]} markers @returns {string|null} */
40
+ function firstMarkedDirectory(context, files, markers) {
41
+ const directories = new Set();
42
+ for (const filePath of files) {
43
+ const relativePath = appRelativePath(context, filePath);
44
+ for (const marker of markers) {
45
+ const directory = prefixThroughSegments(relativePath, marker);
46
+ if (directory) {
47
+ directories.add(directory);
48
+ }
49
+ }
50
+ }
51
+ return [...directories].sort()[0] || null;
52
+ }
53
+
54
+ /** @param {any} context @param {string[]} configFiles @returns {string|null} */
55
+ function drizzleOutPathFromConfig(context, configFiles) {
56
+ for (const configFile of configFiles.sort()) {
57
+ const configText = context.helpers.readTextIfExists(configFile) || "";
58
+ const outMatch = configText.match(/\bout\s*:\s*["'`]([^"'`]+)["'`]/);
59
+ if (!outMatch) {
60
+ continue;
61
+ }
62
+ const absoluteOut = path.resolve(path.dirname(configFile), outMatch[1]);
63
+ const relativeOut = appRelativePath(context, absoluteOut);
64
+ if (relativeOut && !relativeOut.startsWith("..")) {
65
+ return relativeOut;
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * @param {any} context
73
+ * @param {{ tool: "sql"|"prisma"|"drizzle", schemaPath?: string|null, migrationsPath?: string|null, evidence: string[], matchReasons: string[], missingDecisions: string[] }} options
74
+ * @returns {any}
75
+ */
76
+ function maintainedDbSeamCandidate(context, options) {
77
+ const runtimeId = "app_db";
78
+ const projectionId = "proj_db";
79
+ const snapshotPath = `topo/state/db/${runtimeId}/current.snapshot.json`;
80
+ const proposedRuntimeMigration = {
81
+ ownership: "maintained",
82
+ tool: options.tool,
83
+ apply: "never",
84
+ snapshotPath,
85
+ ...(options.schemaPath ? { schemaPath: options.schemaPath } : {}),
86
+ ...(options.migrationsPath ? { migrationsPath: options.migrationsPath } : {})
87
+ };
88
+ const idHint = `seam_${options.tool}_db_migrations`;
89
+
90
+ return makeCandidateRecord({
91
+ kind: "maintained_db_migration_seam",
92
+ idHint,
93
+ label: `${options.tool.toUpperCase()} maintained database migrations`,
94
+ confidence: options.missingDecisions.length === 0 ? "high" : "medium",
95
+ sourceKind: "migration_strategy_inference",
96
+ sourceOfTruth: "candidate",
97
+ provenance: options.evidence,
98
+ track: "db",
99
+ seam_id: idHint,
100
+ output_id: "maintained_app",
101
+ ownership_class: "human_owned",
102
+ status: "review_required",
103
+ tool: options.tool,
104
+ ownership: "maintained",
105
+ apply: "never",
106
+ schemaPath: options.schemaPath || null,
107
+ migrationsPath: options.migrationsPath || null,
108
+ snapshotPath,
109
+ runtime_id_hint: runtimeId,
110
+ projection_id_hint: projectionId,
111
+ evidence: options.evidence,
112
+ match_reasons: options.matchReasons,
113
+ missing_decisions: options.missingDecisions,
114
+ proposed_runtime_migration: proposedRuntimeMigration,
115
+ maintained_modules: [options.schemaPath, options.migrationsPath].filter(Boolean),
116
+ emitted_dependencies: [snapshotPath, projectionId],
117
+ allowed_change_classes: ["proposal_only"],
118
+ drift_signals: ["schema_or_migration_changed", "migration_directory_changed"]
119
+ });
120
+ }
121
+
122
+ /** @param {any} context @param {string[]} prismaFiles @returns {any[]} */
123
+ export function inferPrismaMaintainedDbSeams(context, prismaFiles) {
124
+ if (!prismaFiles.length) {
125
+ return [];
126
+ }
127
+ const schemaPath = appRelativePath(context, prismaFiles[0]);
128
+ const migrationFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => toPosix(filePath).includes("/prisma/migrations/")));
129
+ const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["prisma", "migrations"]]);
130
+ return [
131
+ maintainedDbSeamCandidate(context, {
132
+ tool: "prisma",
133
+ schemaPath,
134
+ migrationsPath,
135
+ evidence: [
136
+ ...prismaFiles.map((filePath) => evidencePath(context, filePath)),
137
+ ...migrationFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
138
+ ],
139
+ matchReasons: [
140
+ "found Prisma schema",
141
+ ...(migrationsPath ? ["found Prisma migrations directory"] : [])
142
+ ],
143
+ missingDecisions: migrationsPath ? [] : ["confirm Prisma migrationsPath before adding this strategy to topogram.project.json"]
144
+ })
145
+ ];
146
+ }
147
+
148
+ /** @param {any} context @param {string[]} schemaFiles @returns {any[]} */
149
+ export function inferDrizzleMaintainedDbSeams(context, schemaFiles) {
150
+ if (!schemaFiles.length) {
151
+ return [];
152
+ }
153
+ const configFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => /drizzle\.config\.(ts|js|mjs|cjs)$/i.test(path.basename(filePath))));
154
+ const drizzleFiles = /** @type {string[]} */ (findImportFiles(context.paths, /** @param {string} filePath */ (filePath) => appRelativePath(context, filePath).startsWith("drizzle/")));
155
+ const configuredOutPath = drizzleOutPathFromConfig(context, configFiles);
156
+ const migrationsPath = configuredOutPath ||
157
+ firstMarkedDirectory(context, drizzleFiles, [["drizzle"]]);
158
+ return [
159
+ maintainedDbSeamCandidate(context, {
160
+ tool: "drizzle",
161
+ schemaPath: appRelativePath(context, schemaFiles[0]),
162
+ migrationsPath,
163
+ evidence: [
164
+ ...schemaFiles.map((filePath) => evidencePath(context, filePath)),
165
+ ...configFiles.map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath)),
166
+ ...drizzleFiles.slice(0, 3).map(/** @param {string} filePath */ (filePath) => evidencePath(context, filePath))
167
+ ],
168
+ matchReasons: [
169
+ "found Drizzle schema source",
170
+ ...(configFiles.length ? ["found Drizzle config"] : []),
171
+ ...(migrationsPath ? ["found Drizzle migrations output"] : [])
172
+ ],
173
+ missingDecisions: migrationsPath ? [] : ["confirm Drizzle migrationsPath before adding this strategy to topogram.project.json"]
174
+ })
175
+ ];
176
+ }
177
+
178
+ /** @param {any} context @param {string[]} allSqlFiles @param {string[]} selectedSqlFiles @returns {any[]} */
179
+ export function inferSqlMaintainedDbSeams(context, allSqlFiles, selectedSqlFiles) {
180
+ if (!allSqlFiles.length) {
181
+ return [];
182
+ }
183
+ const schemaFile = selectedSqlFiles.find((filePath) => !/migration/i.test(path.basename(filePath))) ||
184
+ allSqlFiles.find((filePath) => /schema/i.test(path.basename(filePath))) ||
185
+ null;
186
+ const migrationFiles = allSqlFiles.filter((filePath) => {
187
+ const relativePath = appRelativePath(context, filePath);
188
+ return Boolean(migrationDirectoryFromRelativePath(relativePath)) || /migration/i.test(path.basename(filePath));
189
+ });
190
+ const migrationsPath = firstMarkedDirectory(context, migrationFiles, [["migrations"], ["migration"]]) ||
191
+ (migrationFiles.length ? toPosix(path.dirname(appRelativePath(context, migrationFiles[0]))) : null);
192
+ return [
193
+ maintainedDbSeamCandidate(context, {
194
+ tool: "sql",
195
+ schemaPath: schemaFile ? appRelativePath(context, schemaFile) : null,
196
+ migrationsPath,
197
+ evidence: [
198
+ ...(schemaFile ? [evidencePath(context, schemaFile)] : []),
199
+ ...migrationFiles.slice(0, 3).map((filePath) => evidencePath(context, filePath))
200
+ ],
201
+ matchReasons: [
202
+ ...(schemaFile ? ["found SQL schema"] : []),
203
+ ...(migrationsPath ? ["found SQL migrations directory or migration file"] : [])
204
+ ],
205
+ missingDecisions: migrationsPath ? [] : ["confirm SQL migrationsPath before adding this strategy to topogram.project.json"]
206
+ })
207
+ ];
208
+ }
@@ -8,6 +8,7 @@ import {
8
8
  titleCase,
9
9
  idHintify
10
10
  } from "../../core/shared.js";
11
+ import { inferPrismaMaintainedDbSeams } from "./maintained-seams.js";
11
12
 
12
13
  function parsePrismaSchema(schemaText) {
13
14
  const enums = [];
@@ -125,7 +126,7 @@ export const prismaExtractor = {
125
126
  "prisma"
126
127
  );
127
128
  const findings = [];
128
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
129
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
129
130
  for (const filePath of prismaFiles) {
130
131
  const parsed = parsePrismaSchema(context.helpers.readTextIfExists(filePath) || "");
131
132
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -179,7 +180,7 @@ export const prismaExtractor = {
179
180
  track: "db"
180
181
  })));
181
182
  }
183
+ candidates.maintained_seams = inferPrismaMaintainedDbSeams(context, prismaFiles);
182
184
  return { findings, candidates };
183
185
  }
184
186
  };
185
-
@@ -1,4 +1,5 @@
1
1
  import { canonicalCandidateTerm, findImportFiles, makeCandidateRecord, relativeTo, selectPreferredImportFiles, slugify, titleCase, idHintify } from "../../core/shared.js";
2
+ import { inferSqlMaintainedDbSeams } from "./maintained-seams.js";
2
3
 
3
4
  function parseTableConstraint(line, tableName) {
4
5
  const normalized = line.replace(/,$/, "").trim();
@@ -119,7 +120,7 @@ export const sqlExtractor = {
119
120
  ? selectPreferredImportFiles(context.paths, schemaSqlFiles, "sql")
120
121
  : selectPreferredImportFiles(context.paths, migrationSqlFiles, "sql");
121
122
  const findings = [];
122
- const candidates = { entities: [], enums: [], relations: [], indexes: [] };
123
+ const candidates = { entities: [], enums: [], relations: [], indexes: [], maintained_seams: [] };
123
124
  for (const filePath of sqlFiles) {
124
125
  const parsed = parseSqlSchema(context.helpers.readTextIfExists(filePath) || "");
125
126
  const provenance = relativeTo(context.paths.repoRoot, filePath);
@@ -175,6 +176,7 @@ export const sqlExtractor = {
175
176
  track: "db"
176
177
  })));
177
178
  }
179
+ candidates.maintained_seams = inferSqlMaintainedDbSeams(context, allSqlFiles, sqlFiles);
178
180
  return { findings, candidates };
179
181
  }
180
182
  };
@@ -81,7 +81,7 @@ export function bestContextBundleForCandidate(bundles, candidate) {
81
81
 
82
82
  /** @param {ResolvedGraph} graph @param {ImportArtifacts} appImport @param {any} topogramRoot @returns {any} */
83
83
  export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
84
- const dbCandidates = appImport.candidates.db || { entities: [], enums: [] };
84
+ const dbCandidates = appImport.candidates.db || { entities: [], enums: [], maintained_seams: [] };
85
85
  const apiCandidates = appImport.candidates.api || { capabilities: [] };
86
86
  const uiCandidates = appImport.candidates.ui || { screens: [], routes: [], actions: [], widgets: [], shapes: [] };
87
87
  const uiWidgetCandidates = uiCandidates.widgets || uiCandidates.components || [];
@@ -151,6 +151,10 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
151
151
  bundles.delete(`enum_${enumId}`);
152
152
  }
153
153
  }
154
+ for (const seam of dbCandidates.maintained_seams || []) {
155
+ const bundle = getOrCreateCandidateBundle(bundles, "database", "Database");
156
+ bundle.maintainedSeams = [...(bundle.maintainedSeams || []), seam];
157
+ }
154
158
  for (const entry of apiCandidates.capabilities || []) {
155
159
  const matchedCapability = graph ? matchImportedApiCapability(entry, topogramApiCapabilities) : null;
156
160
  if (matchedCapability) {
@@ -333,7 +337,8 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
333
337
  bundle.workflows.length > 0 ||
334
338
  bundle.verifications.length > 0 ||
335
339
  bundle.workflowStates.length > 0 ||
336
- bundle.workflowTransitions.length > 0
340
+ bundle.workflowTransitions.length > 0 ||
341
+ (bundle.maintainedSeams || []).length > 0
337
342
  )
338
343
  .map((/** @type {any} */ bundle) => {
339
344
  const sortedBundle = {
@@ -353,6 +358,7 @@ export function buildCandidateModelBundles(graph, appImport, topogramRoot) {
353
358
  verifications: bundle.verifications.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
354
359
  workflowStates: bundle.workflowStates.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
355
360
  workflowTransitions: bundle.workflowTransitions.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
361
+ maintainedSeams: (bundle.maintainedSeams || []).sort((/** @type {any} */ a, /** @type {any} */ b) => a.id_hint.localeCompare(b.id_hint)),
356
362
  docs: bundle.docs.sort((/** @type {any} */ a, /** @type {any} */ b) => a.id.localeCompare(b.id))
357
363
  };
358
364
  const mergeHints = buildBundleMergeHints(sortedBundle, canonicalEntityIds);
@@ -91,6 +91,7 @@ export function reconcileWorkflow(inputPath, options = {}) {
91
91
  type: "reconcile_adoption_plan",
92
92
  workspace: paths.topogramRoot,
93
93
  approved_review_groups: [...new Set(existingPlan?.approved_review_groups || [])],
94
+ imported_maintained_db_seams: appImport.candidates?.db?.maintained_seams || [],
94
95
  items: mergedPlanItems,
95
96
  projection_review_groups: buildProjectionReviewGroups(mergedPlanItems),
96
97
  ui_review_groups: buildUiReviewGroups(mergedPlanItems),