@topogram/cli 0.3.74 → 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.74",
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
  }
@@ -31,7 +31,8 @@ import { DEFAULT_TOPO_FOLDER_NAME, resolveTopoRoot, resolveWorkspaceContext } fr
31
31
  * @typedef {{ command: string, reason: string, phase: string }} AgentBriefCommand
32
32
  * @typedef {{ path: string, ownership: string, rule: string }} AgentBriefOutputBoundary
33
33
  * @typedef {{ id: string, title: string, commands: string[], rule: string }} AgentBriefWorkflow
34
- * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null }} AgentBriefRuntime
34
+ * @typedef {{ ownership: string|null, tool: string|null, apply: string|null, statePath: string|null, snapshotPath: string|null, schemaPath: string|null, migrationsPath: string|null }} AgentBriefMigration
35
+ * @typedef {{ id: string, kind: string, projection: string|null, generator: string|null, uses_api: string|null, uses_database: string|null, migration: AgentBriefMigration|null }} AgentBriefRuntime
35
36
  * @typedef {{ path: string, workspaceRoot: string, source: string|null, tracks: string[], candidateCounts: Record<string, any>, ownership: string|null }} AgentBriefImport
36
37
  * @typedef {{ ok: true, payload: Record<string, any> } | { ok: false, kind: "topogram", validation: any } | { ok: false, kind: "project", validation: any, configPath: string }} AgentBriefResult
37
38
  */
@@ -103,7 +104,18 @@ function summarizeRuntimes(config) {
103
104
  projection: typeof runtime.projection === "string" ? runtime.projection : null,
104
105
  generator: typeof runtime.generator?.id === "string" ? runtime.generator.id : null,
105
106
  uses_api: typeof runtime.uses_api === "string" ? runtime.uses_api : null,
106
- uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null
107
+ uses_database: typeof runtime.uses_database === "string" ? runtime.uses_database : null,
108
+ migration: runtime.kind === "database" && runtime.migration
109
+ ? {
110
+ ownership: typeof runtime.migration.ownership === "string" ? runtime.migration.ownership : null,
111
+ tool: typeof runtime.migration.tool === "string" ? runtime.migration.tool : null,
112
+ apply: typeof runtime.migration.apply === "string" ? runtime.migration.apply : null,
113
+ statePath: typeof runtime.migration.statePath === "string" ? runtime.migration.statePath : null,
114
+ snapshotPath: typeof runtime.migration.snapshotPath === "string" ? runtime.migration.snapshotPath : null,
115
+ schemaPath: typeof runtime.migration.schemaPath === "string" ? runtime.migration.schemaPath : null,
116
+ migrationsPath: typeof runtime.migration.migrationsPath === "string" ? runtime.migration.migrationsPath : null
117
+ }
118
+ : null
107
119
  }));
108
120
  }
109
121
 
@@ -495,7 +507,10 @@ export function formatAgentBrief(brief) {
495
507
  runtime.uses_api ? `uses_api=${runtime.uses_api}` : null,
496
508
  runtime.uses_database ? `uses_database=${runtime.uses_database}` : null
497
509
  ].filter(Boolean).join(", ");
498
- lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}`);
510
+ const migration = runtime.migration
511
+ ? ` migration=${runtime.migration.ownership}/${runtime.migration.tool} apply=${runtime.migration.apply}`
512
+ : "";
513
+ lines.push(` - ${runtime.id}: ${runtime.kind}${runtime.projection ? ` -> ${runtime.projection}` : ""}${runtime.generator ? ` via ${runtime.generator}` : ""}${edges ? ` (${edges})` : ""}${migration}`);
499
514
  }
500
515
  if ((brief.topology?.runtimes || []).length === 0) {
501
516
  lines.push(" - No topology runtimes configured.");
@@ -80,6 +80,17 @@ function summarizeProjectTopology(config) {
80
80
  version: component.generator?.version || null
81
81
  },
82
82
  port: topologyComponentPort(component),
83
+ migration: component.kind === "database" && component.migration
84
+ ? {
85
+ ownership: component.migration.ownership || null,
86
+ tool: component.migration.tool || null,
87
+ apply: component.migration.apply || null,
88
+ statePath: component.migration.statePath || null,
89
+ snapshotPath: component.migration.snapshotPath || null,
90
+ schemaPath: component.migration.schemaPath || null,
91
+ migrationsPath: component.migration.migrationsPath || null
92
+ }
93
+ : null,
83
94
  references: topologyComponentReferences(component)
84
95
  }))
85
96
  .sort((left, right) => left.id.localeCompare(right.id));
@@ -136,7 +147,10 @@ function formatTopologyComponent(component) {
136
147
  .filter(([, value]) => Boolean(value))
137
148
  .map(([key, value]) => `${key} ${value}`);
138
149
  const suffix = refs.length ? ` -> ${refs.join(", ")}` : "";
139
- return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}`;
150
+ const migration = component.migration
151
+ ? ` [migration ${component.migration.ownership}/${component.migration.tool}, apply=${component.migration.apply}]`
152
+ : "";
153
+ return ` - ${component.id}: ${component.kind} ${component.projection} via ${generator} (${port})${suffix}${migration}`;
140
154
  }
141
155
 
142
156
  /**
@@ -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,
@@ -177,7 +177,7 @@ function buildContractsForContext(context) {
177
177
  if (surface === "database") {
178
178
  return {
179
179
  db: generateDbContractGraph(context.graph, { ...(context.options || {}), projectionId }),
180
- lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId })
180
+ lifecyclePlan: generateDbLifecyclePlan(context.graph, { ...(context.options || {}), projectionId, runtime, component: runtime, topology: context.topology })
181
181
  };
182
182
  }
183
183
  if (surface === "native" || surface === "ios_surface" || surface === "android_surface") {
@@ -48,7 +48,7 @@ function buildAppBundlePlan(graph, options = {}) {
48
48
  const runtimeReference = runtimeReferenceFor(graph, options);
49
49
  const topology = resolveRuntimeTopology(graph, options);
50
50
  const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options);
51
- const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id }) : null;
51
+ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null;
52
52
  const environmentProfile = options.profileId || "local_process";
53
53
  const deployProfile = options.deployProfileId || "fly_io";
54
54
  const smokeVerification = buildVerificationSummary(graph, ["smoke", "journey"]);
@@ -77,7 +77,8 @@ function buildAppBundlePlan(graph, options = {}) {
77
77
  generator: runtime.generator,
78
78
  port: runtime.port ?? null,
79
79
  uses_api: runtime.api || null,
80
- uses_database: runtime.database || null
80
+ uses_database: runtime.database || null,
81
+ ...(runtime.kind === "database" && runtime.migration ? { migration: runtime.migration } : {})
81
82
  }))
82
83
  },
83
84
  runtimeReference,
@@ -43,7 +43,7 @@ function buildEnvironmentPlan(graph, options = {}) {
43
43
  const runtimeReference = runtimeReferenceFor(graph, options);
44
44
  const topology = resolveRuntimeTopology(graph, options);
45
45
  const { apiProjection, uiProjection, dbProjection } = getDefaultEnvironmentProjections(graph, options);
46
- const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id }) : null;
46
+ const dbLifecycle = dbProjection ? generateDbLifecyclePlan(graph, { ...options, projectionId: dbProjection.id, runtime: topology.primaryDb || undefined }) : null;
47
47
  const dbEngine = dbLifecycle?.engine || null;
48
48
  const profile = options.profileId || (dbEngine === "sqlite" || !dbProjection ? "local_process" : "local_docker");
49
49
  const usesDocker = profile === "local_docker";
@@ -67,7 +67,8 @@ function buildEnvironmentPlan(graph, options = {}) {
67
67
  generator: runtime.generator,
68
68
  port: runtime.port ?? null,
69
69
  uses_api: runtime.api || null,
70
- uses_database: runtime.database || null
70
+ uses_database: runtime.database || null,
71
+ ...(runtime.kind === "database" && runtime.migration ? { migration: runtime.migration } : {})
71
72
  }))
72
73
  },
73
74
  projections: {
@@ -145,7 +146,8 @@ function buildEnvironmentPlan(graph, options = {}) {
145
146
  engine: component.id === topology.primaryDb?.id ? dbEngine : null,
146
147
  port: component.port,
147
148
  dir: topology.dbDir(component),
148
- env: dbEnvVarsForComponent(component, { primary: component.id === topology.primaryDb?.id })
149
+ env: dbEnvVarsForComponent(component, { primary: component.id === topology.primaryDb?.id }),
150
+ ...(component.migration ? { migration: component.migration } : {})
149
151
  }))
150
152
  },
151
153
  ports: {
@@ -329,7 +331,8 @@ ${dockerSection}
329
331
  - ${hasWeb && hasApi ? "The generated web app talks to `PUBLIC_TOPOGRAM_API_BASE_URL`." : hasWeb ? "The generated web app is standalone." : "No web surface is generated."}
330
332
  - ${hasNative ? "Native app scaffolds use the same UI surface contracts as web surfaces." : "No native surface is generated."}
331
333
  - If \`.env\` is missing, generated scripts fall back to \`.env.example\`.
332
- - The DB lifecycle scripts remain the source of truth for greenfield bootstrap and brownfield migration.
334
+ - Generated-owned DB lifecycle scripts remain the source of truth for greenfield bootstrap and generated migrations.
335
+ - Maintained DB lifecycle scripts emit proposals only and never apply migrations.
333
336
  `;
334
337
  }
335
338
 
@@ -349,6 +352,10 @@ function renderEnvironmentBootstrapDbScript(plan) {
349
352
  return `(cd "$ROOT_DIR/${component.dir}" && TOPOGRAM_ENV_FILE=/dev/null ${assignments} bash ./scripts/db-bootstrap-or-migrate.sh)`;
350
353
  });
351
354
  const primaryApi = plan.runtimes.apis[0];
355
+ const primaryApiDatabase = primaryApi?.uses_database
356
+ ? plan.runtimes.databases.find((component) => component.id === primaryApi.uses_database)
357
+ : null;
358
+ const seedAllowed = Boolean(primaryApi?.uses_database) && primaryApiDatabase?.migration?.ownership !== "maintained";
352
359
  if (plan.runtimes.databases.length === 0) {
353
360
  return renderEnvAwareShellScript([
354
361
  'echo "No database runtimes are configured; skipping DB bootstrap."'
@@ -366,9 +373,11 @@ function renderEnvironmentBootstrapDbScript(plan) {
366
373
  ...dbBootstrapLines,
367
374
  'if [[ "${TOPOGRAM_SEED_DEMO:-true}" != "false" ]]; then',
368
375
  ...apiDatabaseExportLines(primaryApi),
369
- ...(primaryApi?.uses_database
376
+ ...(seedAllowed
370
377
  ? [`(cd "$ROOT_DIR/${primaryApi.dir}" && npm install && npm exec -- prisma generate --schema prisma/schema.prisma && npm exec -- prisma db push --schema prisma/schema.prisma --skip-generate && npm run seed:demo)`]
371
- : ['echo "No DB-backed API component is configured; skipping demo seed."']),
378
+ : [primaryApi?.uses_database
379
+ ? 'echo "Primary API uses a maintained database runtime; skipping generated demo seed."'
380
+ : 'echo "No DB-backed API component is configured; skipping demo seed."']),
372
381
  "fi"
373
382
  ]);
374
383
  }
@@ -27,6 +27,7 @@ import { defaultProjectConfigForGraph, validateProjectConfig } from "../../../pr
27
27
  * @property {string|null} [api]
28
28
  * @property {string|null} [database]
29
29
  * @property {Record<string, string>} [env]
30
+ * @property {import("../../../project-config.js").RuntimeMigrationStrategy} [migration]
30
31
  * @property {RuntimeComponent|null} [apiRuntime]
31
32
  * @property {RuntimeComponent|null} [databaseRuntime]
32
33
  * @property {RuntimeComponent|null} [apiComponent] Legacy adapter alias for apiRuntime.
@@ -17,11 +17,65 @@ function defaultInputPathForGraph(graph, options = {}) {
17
17
  return ".";
18
18
  }
19
19
 
20
+ function runtimeMigrationStrategyForProjection(projection, options = {}) {
21
+ const projectionId = projection?.id || options.projectionId || null;
22
+ const runtime = options.runtime || options.component || null;
23
+ if (runtime?.kind === "database" && runtime?.migration && (!projectionId || runtime.projection?.id === projectionId || runtime.projection === projectionId)) {
24
+ return runtime.migration;
25
+ }
26
+
27
+ const topologyRuntime = (options.topology?.dbRuntimes || options.topology?.runtimes || [])
28
+ .find((entry) => entry?.kind === "database" && entry?.migration && (entry.projection?.id === projectionId || entry.projection === projectionId));
29
+ if (topologyRuntime?.migration) {
30
+ return topologyRuntime.migration;
31
+ }
32
+
33
+ const configRuntime = (options.projectConfig?.topology?.runtimes || [])
34
+ .find((entry) => entry?.kind === "database" && entry?.migration && entry.projection === projectionId);
35
+ return configRuntime?.migration || null;
36
+ }
37
+
38
+ function normalizeMigrationStrategy(strategy) {
39
+ if (!strategy) {
40
+ return {
41
+ ownership: "generated",
42
+ tool: "sql",
43
+ apply: "script",
44
+ statePath: null,
45
+ snapshotPath: null,
46
+ schemaPath: null,
47
+ migrationsPath: null,
48
+ defaulted: true
49
+ };
50
+ }
51
+ return {
52
+ ownership: strategy.ownership || "generated",
53
+ tool: strategy.tool || "sql",
54
+ apply: strategy.apply || (strategy.ownership === "maintained" ? "never" : "script"),
55
+ statePath: strategy.statePath || null,
56
+ snapshotPath: strategy.snapshotPath || null,
57
+ schemaPath: strategy.schemaPath || null,
58
+ migrationsPath: strategy.migrationsPath || null,
59
+ defaulted: false
60
+ };
61
+ }
62
+
63
+ function pathJoinPosix(...parts) {
64
+ return parts.filter(Boolean).join("/").replace(/\/+/g, "/");
65
+ }
66
+
20
67
  function dbLifecyclePlan(graph, projection, options = {}) {
21
68
  const contract = buildDbProjectionContract(graph, projection);
22
69
  const snapshot = normalizeDbSchemaSnapshot(contract);
23
70
  const engine = snapshot.engine;
24
71
  const ormProfiles = engine === "postgres" ? ["prisma", "drizzle"] : ["prisma"];
72
+ const migrationStrategy = normalizeMigrationStrategy(runtimeMigrationStrategyForProjection(projection, options));
73
+ const generatedStateRoot = migrationStrategy.statePath || "state";
74
+ const proposalStateRoot = "state";
75
+ const stateRoot = migrationStrategy.ownership === "generated" ? generatedStateRoot : proposalStateRoot;
76
+ const currentSnapshot = migrationStrategy.ownership === "maintained" && migrationStrategy.snapshotPath
77
+ ? migrationStrategy.snapshotPath
78
+ : pathJoinPosix(stateRoot, "current.snapshot.json");
25
79
 
26
80
  return {
27
81
  type: "db_lifecycle_plan",
@@ -31,19 +85,26 @@ function dbLifecyclePlan(graph, projection, options = {}) {
31
85
  ormProfiles,
32
86
  runtimeProfile: ormProfiles.includes("prisma") ? "prisma" : null,
33
87
  inputPath: defaultInputPathForGraph(graph, options),
88
+ migrationStrategy,
34
89
  state: {
35
- currentSnapshot: "state/current.snapshot.json",
36
- desiredSnapshot: "state/desired.snapshot.json",
37
- migrationPlan: "state/migration.plan.json",
38
- migrationSql: "state/migration.sql"
90
+ root: stateRoot,
91
+ currentSnapshot,
92
+ desiredSnapshot: pathJoinPosix(stateRoot, "desired.snapshot.json"),
93
+ migrationPlan: pathJoinPosix(stateRoot, "migration.plan.json"),
94
+ migrationSql: pathJoinPosix(stateRoot, "migration.sql")
39
95
  },
40
96
  bundle: {
41
97
  emptySnapshot: "snapshots/empty.snapshot.json",
42
98
  prismaSchema: ormProfiles.includes("prisma") ? "prisma/schema.prisma" : null,
43
99
  drizzleSchema: engine === "postgres" ? "drizzle/schema.ts" : null
44
100
  },
101
+ proposals: {
102
+ schemaPath: migrationStrategy.schemaPath,
103
+ migrationsPath: migrationStrategy.migrationsPath,
104
+ snapshotPath: migrationStrategy.snapshotPath
105
+ },
45
106
  environment: {
46
- required: ["DATABASE_URL"],
107
+ required: migrationStrategy.ownership === "maintained" ? [] : ["DATABASE_URL"],
47
108
  optional:
48
109
  engine === "postgres"
49
110
  ? ["DATABASE_ADMIN_URL", "TOPOGRAM_BIN", "TOPOGRAM_INPUT_PATH", "TOPOGRAM_DB_STATE_DIR"]
@@ -75,6 +136,14 @@ function dbLifecyclePlan(graph, projection, options = {}) {
75
136
  "persist desired snapshot as current"
76
137
  ],
77
138
  safety: "manual migration required plans are never auto-applied"
139
+ },
140
+ behavior: {
141
+ ownership: migrationStrategy.ownership,
142
+ tool: migrationStrategy.tool,
143
+ apply: migrationStrategy.apply,
144
+ appliesMigrations: migrationStrategy.ownership === "generated" && migrationStrategy.apply === "script",
145
+ writesGeneratedState: migrationStrategy.ownership === "generated",
146
+ proposalOnly: migrationStrategy.ownership === "maintained"
78
147
  }
79
148
  };
80
149
  }
@@ -108,10 +177,28 @@ function renderDbLifecycleEnvExample(projection, plan) {
108
177
  }
109
178
 
110
179
  function renderDbLifecycleReadme(plan) {
180
+ const proposalLines = [
181
+ plan.proposals.snapshotPath ? `- Current snapshot source: \`${plan.proposals.snapshotPath}\`` : null,
182
+ plan.proposals.schemaPath ? `- Maintained schema path: \`${plan.proposals.schemaPath}\`` : null,
183
+ plan.proposals.migrationsPath ? `- Maintained migrations path: \`${plan.proposals.migrationsPath}\`` : null
184
+ ].filter(Boolean).join("\n");
185
+ const modeText = plan.behavior.proposalOnly
186
+ ? `Maintained proposal mode. Topogram emits desired snapshots, migration plans, SQL proposals, and schema proposals, but these scripts do not apply migrations to the database. A human or agent must adapt accepted proposals into the maintained migration system.`
187
+ : `Generated apply mode. Topogram owns this lifecycle bundle and scripts may apply supported generated SQL migrations. Unsupported or destructive migration plans stop for manual review.`;
111
188
  return `# ${plan.projection.name} Lifecycle
112
189
 
113
190
  This bundle gives agents a repeatable database workflow for projection \`${plan.projection.id}\`.
114
191
 
192
+ ## Migration Strategy
193
+
194
+ - Ownership: \`${plan.migrationStrategy.ownership}\`
195
+ - Tool: \`${plan.migrationStrategy.tool}\`
196
+ - Apply: \`${plan.migrationStrategy.apply}\`
197
+ - Defaulted: \`${plan.migrationStrategy.defaulted ? "yes" : "no"}\`
198
+
199
+ ${modeText}
200
+ ${proposalLines ? `\n${proposalLines}\n` : ""}
201
+
115
202
  ## Modes
116
203
 
117
204
  - Greenfield: run \`./scripts/db-bootstrap-or-migrate.sh\` with no current snapshot
@@ -143,6 +230,12 @@ ${plan.environment.optional.map((name) => `- \`${name}\``).join("\n")}
143
230
 
144
231
  function renderDbLifecycleCommonScript(plan) {
145
232
  const engine = plan.engine;
233
+ const configuredStatePath = plan.migrationStrategy.ownership === "generated" && plan.migrationStrategy.statePath
234
+ ? plan.migrationStrategy.statePath
235
+ : "";
236
+ const configuredSnapshotPath = plan.migrationStrategy.ownership === "maintained" && plan.migrationStrategy.snapshotPath
237
+ ? plan.migrationStrategy.snapshotPath
238
+ : "";
146
239
  return `#!/usr/bin/env bash
147
240
  set -euo pipefail
148
241
 
@@ -219,9 +312,24 @@ discover_input_path() {
219
312
  }
220
313
  TOPOGRAM_BIN="$(find_topogram_bin)"
221
314
  INPUT_PATH="$(discover_input_path)"
315
+ if [[ -f "$INPUT_PATH/topogram.project.json" ]]; then
316
+ PROJECT_ROOT="$INPUT_PATH"
317
+ else
318
+ PROJECT_ROOT="$(cd "$INPUT_PATH/.." && pwd)"
319
+ fi
222
320
  PROJECTION_ID="${plan.projection.id}"
223
- STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$BUNDLE_DIR/state}"
224
- CURRENT_SNAPSHOT="$STATE_DIR/current.snapshot.json"
321
+ CONFIGURED_STATE_PATH=${JSON.stringify(configuredStatePath)}
322
+ CONFIGURED_SNAPSHOT_PATH=${JSON.stringify(configuredSnapshotPath)}
323
+ if [[ -n "$CONFIGURED_STATE_PATH" ]]; then
324
+ STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$PROJECT_ROOT/$CONFIGURED_STATE_PATH}"
325
+ else
326
+ STATE_DIR="\${TOPOGRAM_DB_STATE_DIR:-$BUNDLE_DIR/state}"
327
+ fi
328
+ if [[ -n "$CONFIGURED_SNAPSHOT_PATH" ]]; then
329
+ CURRENT_SNAPSHOT="$PROJECT_ROOT/$CONFIGURED_SNAPSHOT_PATH"
330
+ else
331
+ CURRENT_SNAPSHOT="$STATE_DIR/current.snapshot.json"
332
+ fi
225
333
  DESIRED_SNAPSHOT="$STATE_DIR/desired.snapshot.json"
226
334
  PLAN_JSON="$STATE_DIR/migration.plan.json"
227
335
  MIGRATION_SQL="$STATE_DIR/migration.sql"
@@ -429,7 +537,7 @@ ${engine === "sqlite" ? ` normalize_sqlite_database_url
429
537
  `;
430
538
  }
431
539
 
432
- function renderDbStatusScript() {
540
+ function renderDbStatusScript(plan) {
433
541
  return `#!/usr/bin/env bash
434
542
  set -euo pipefail
435
543
  . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
@@ -440,11 +548,26 @@ if [[ -f "$CURRENT_SNAPSHOT" ]]; then
440
548
  generate_migration_plan "$CURRENT_SNAPSHOT"
441
549
  cat "$PLAN_JSON"
442
550
  else
443
- echo '{"mode":"greenfield","currentSnapshot":null}'
551
+ echo ${JSON.stringify(plan.behavior.proposalOnly ? '{"mode":"maintained_proposal","currentSnapshot":null}' : '{"mode":"greenfield","currentSnapshot":null}')}
444
552
  fi
445
553
  `;
446
554
  }
447
555
 
556
+ function renderMaintainedDbBootstrapScript() {
557
+ return `#!/usr/bin/env bash
558
+ set -euo pipefail
559
+ . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
560
+
561
+ generate_desired_snapshot
562
+ generate_sql_migration "$EMPTY_SNAPSHOT"
563
+
564
+ echo "Maintained database runtime: bootstrap is proposal-only."
565
+ echo "Desired snapshot: $DESIRED_SNAPSHOT"
566
+ echo "SQL proposal: $MIGRATION_SQL"
567
+ echo "No migration was applied. Review and adapt the proposal into the maintained database migration system."
568
+ `;
569
+ }
570
+
448
571
  function renderDbBootstrapScript() {
449
572
  return `#!/usr/bin/env bash
450
573
  set -euo pipefail
@@ -489,6 +612,30 @@ echo "Greenfield bootstrap complete."
489
612
  `;
490
613
  }
491
614
 
615
+ function renderMaintainedDbMigrateScript() {
616
+ return `#!/usr/bin/env bash
617
+ set -euo pipefail
618
+ . "$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)/db-common.sh"
619
+
620
+ generate_desired_snapshot
621
+
622
+ if [[ ! -f "$CURRENT_SNAPSHOT" ]]; then
623
+ echo "Maintained database runtime: current snapshot not found at $CURRENT_SNAPSHOT." >&2
624
+ echo "Create the reviewed snapshot first, then rerun this proposal command." >&2
625
+ exit 1
626
+ fi
627
+
628
+ generate_migration_plan "$CURRENT_SNAPSHOT"
629
+ ensure_supported_plan
630
+ generate_sql_migration "$CURRENT_SNAPSHOT"
631
+
632
+ echo "Maintained database runtime: migration proposal generated."
633
+ echo "Migration plan: $PLAN_JSON"
634
+ echo "SQL proposal: $MIGRATION_SQL"
635
+ echo "No migration was applied. Review and adapt the proposal into the maintained database migration system."
636
+ `;
637
+ }
638
+
492
639
  function renderDbMigrateScript() {
493
640
  return `#!/usr/bin/env bash
494
641
  set -euo pipefail
@@ -524,6 +671,21 @@ echo "Brownfield migration complete."
524
671
  `;
525
672
  }
526
673
 
674
+ function renderMaintainedDbBootstrapOrMigrateScript() {
675
+ return `#!/usr/bin/env bash
676
+ set -euo pipefail
677
+
678
+ SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
679
+ . "$SCRIPT_DIR/db-common.sh"
680
+
681
+ if [[ -f "$CURRENT_SNAPSHOT" ]]; then
682
+ exec bash "$SCRIPT_DIR/db-migrate.sh"
683
+ fi
684
+
685
+ exec bash "$SCRIPT_DIR/db-bootstrap.sh"
686
+ `;
687
+ }
688
+
527
689
  function renderDbBootstrapOrMigrateScript() {
528
690
  return `#!/usr/bin/env bash
529
691
  set -euo pipefail
@@ -571,10 +733,10 @@ function generateDbLifecycleBundle(graph, projection, options = {}) {
571
733
  "README.md": renderDbLifecycleReadme(plan),
572
734
  ".env.example": renderDbLifecycleEnvExample(projection, plan),
573
735
  "scripts/db-common.sh": renderDbLifecycleCommonScript(plan),
574
- "scripts/db-status.sh": renderDbStatusScript(),
575
- "scripts/db-bootstrap.sh": renderDbBootstrapScript(),
576
- "scripts/db-migrate.sh": renderDbMigrateScript(),
577
- "scripts/db-bootstrap-or-migrate.sh": renderDbBootstrapOrMigrateScript(),
736
+ "scripts/db-status.sh": renderDbStatusScript(plan),
737
+ "scripts/db-bootstrap.sh": plan.behavior.proposalOnly ? renderMaintainedDbBootstrapScript() : renderDbBootstrapScript(),
738
+ "scripts/db-migrate.sh": plan.behavior.proposalOnly ? renderMaintainedDbMigrateScript() : renderDbMigrateScript(),
739
+ "scripts/db-bootstrap-or-migrate.sh": plan.behavior.proposalOnly ? renderMaintainedDbBootstrapOrMigrateScript() : renderDbBootstrapOrMigrateScript(),
578
740
  "snapshots/empty.snapshot.json": `${JSON.stringify(renderEmptySnapshotForProjection(projection), null, 2)}\n`,
579
741
  "state/.gitkeep": ""
580
742
  };
@@ -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: [] };