@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 +1 -1
- package/src/adoption/plan/index.js +57 -6
- package/src/agent-brief.js +18 -3
- package/src/cli/commands/check.js +15 -1
- package/src/cli/commands/import/workspace.js +1 -0
- package/src/generator/adapters.js +1 -1
- package/src/generator/runtime/app-bundle.js +3 -2
- package/src/generator/runtime/environment/index.js +15 -6
- package/src/generator/runtime/shared/index.js +1 -0
- package/src/generator/surfaces/databases/lifecycle-shared.js +175 -13
- package/src/import/core/runner/candidates.js +2 -1
- package/src/import/core/runner/reports.js +5 -2
- package/src/import/core/runner/tracks.js +1 -1
- package/src/import/extractors/cli/generic.js +107 -40
- package/src/import/extractors/db/drizzle.js +35 -4
- package/src/import/extractors/db/maintained-seams.js +208 -0
- package/src/import/extractors/db/prisma.js +3 -2
- package/src/import/extractors/db/sql.js +3 -1
- package/src/project-config/index.js +97 -0
- package/src/project-config.js +1 -0
- package/src/workflows/reconcile/candidate-model.js +8 -2
- package/src/workflows/reconcile/workflow.js +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
}
|
package/src/agent-brief.js
CHANGED
|
@@ -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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
...(
|
|
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
|
-
: [
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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: [] };
|