@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.
@@ -18,6 +18,17 @@ import { DEFAULT_WORKSPACE_PATH, normalizeWorkspaceConfigPath } from "../workspa
18
18
  * @property {string} [package]
19
19
  */
20
20
 
21
+ /**
22
+ * @typedef {Object} RuntimeMigrationStrategy
23
+ * @property {"generated"|"maintained"} ownership
24
+ * @property {"sql"|"prisma"|"drizzle"} tool
25
+ * @property {"never"|"script"} apply
26
+ * @property {string} [statePath]
27
+ * @property {string} [snapshotPath]
28
+ * @property {string} [schemaPath]
29
+ * @property {string} [migrationsPath]
30
+ */
31
+
21
32
  /**
22
33
  * @typedef {Object} RuntimeTopologyRuntime
23
34
  * @property {string} id
@@ -28,6 +39,7 @@ import { DEFAULT_WORKSPACE_PATH, normalizeWorkspaceConfigPath } from "../workspa
28
39
  * @property {string} [uses_api]
29
40
  * @property {string} [uses_database]
30
41
  * @property {Record<string, string>} [env]
42
+ * @property {RuntimeMigrationStrategy} [migration]
31
43
  */
32
44
 
33
45
  /**
@@ -58,6 +70,10 @@ const PROJECT_CONFIG_FILE = "topogram.project.json";
58
70
  const LEGACY_IMPLEMENTATION_FILE = "topogram.implementation.json";
59
71
  const GENERATED_OUTPUT_SENTINEL = ".topogram-generated.json";
60
72
  const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;
73
+ const MIGRATION_OWNERSHIPS = new Set(["generated", "maintained"]);
74
+ const MIGRATION_TOOLS = new Set(["sql", "prisma", "drizzle"]);
75
+ const MIGRATION_APPLY_MODES = new Set(["never", "script"]);
76
+ const MIGRATION_PATH_FIELDS = ["statePath", "snapshotPath", "schemaPath", "migrationsPath"];
61
77
 
62
78
  /**
63
79
  * @param {string|null|undefined} root
@@ -249,6 +265,21 @@ function validateWorkspaceConfig(errors, config) {
249
265
  }
250
266
  }
251
267
 
268
+ /**
269
+ * @param {string} value
270
+ * @returns {boolean}
271
+ */
272
+ function isProjectRelativePath(value) {
273
+ if (typeof value !== "string" || value.trim().length === 0) {
274
+ return false;
275
+ }
276
+ if (path.isAbsolute(value)) {
277
+ return false;
278
+ }
279
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/"));
280
+ return normalized !== ".." && !normalized.startsWith("../");
281
+ }
282
+
252
283
  /**
253
284
  * @param {string} root
254
285
  * @returns {ProjectConfigInfo|null}
@@ -388,9 +419,75 @@ function validateRuntimeShape(errors, runtime, seenIds) {
388
419
  if (runtime.port != null && (!Number.isInteger(runtime.port) || runtime.port <= 0 || runtime.port > 65535)) {
389
420
  pushError(errors, `${runtimeLabel(runtime)} port must be an integer from 1 to 65535`);
390
421
  }
422
+ validateRuntimeMigrationStrategy(errors, runtime);
391
423
  return true;
392
424
  }
393
425
 
426
+ /**
427
+ * @param {ValidationError[]} errors
428
+ * @param {any} runtime
429
+ * @returns {void}
430
+ */
431
+ function validateRuntimeMigrationStrategy(errors, runtime) {
432
+ if (runtime.migration == null) {
433
+ return;
434
+ }
435
+ if (runtime.kind !== "database") {
436
+ pushError(errors, `${runtimeLabel(runtime)} migration is only supported on database runtimes`);
437
+ return;
438
+ }
439
+ const migration = runtime.migration;
440
+ if (!migration || typeof migration !== "object" || Array.isArray(migration)) {
441
+ pushError(errors, `${runtimeLabel(runtime)} migration must be an object`);
442
+ return;
443
+ }
444
+ if (!MIGRATION_OWNERSHIPS.has(migration.ownership)) {
445
+ pushError(errors, `${runtimeLabel(runtime)} migration.ownership must be generated or maintained`);
446
+ }
447
+ if (!MIGRATION_TOOLS.has(migration.tool)) {
448
+ pushError(errors, `${runtimeLabel(runtime)} migration.tool must be sql, prisma, or drizzle`);
449
+ }
450
+ if (!MIGRATION_APPLY_MODES.has(migration.apply)) {
451
+ pushError(errors, `${runtimeLabel(runtime)} migration.apply must be never or script`);
452
+ }
453
+ if (migration.ownership === "maintained" && migration.apply !== "never") {
454
+ pushError(errors, `${runtimeLabel(runtime)} maintained migration.apply must be never`);
455
+ }
456
+ if (migration.ownership === "generated" && migration.apply !== "script") {
457
+ pushError(errors, `${runtimeLabel(runtime)} generated migration.apply must be script`);
458
+ }
459
+ for (const field of MIGRATION_PATH_FIELDS) {
460
+ if (migration[field] != null && !isProjectRelativePath(migration[field])) {
461
+ pushError(errors, `${runtimeLabel(runtime)} migration.${field} must be a non-empty relative path that stays inside the project root`);
462
+ }
463
+ }
464
+ if (migration.ownership === "generated" && typeof migration.statePath !== "string") {
465
+ pushError(errors, `${runtimeLabel(runtime)} generated migration requires statePath`);
466
+ }
467
+ if (migration.ownership === "maintained" && typeof migration.snapshotPath !== "string") {
468
+ pushError(errors, `${runtimeLabel(runtime)} maintained migration requires snapshotPath`);
469
+ }
470
+ if (migration.ownership === "maintained" && migration.tool === "prisma") {
471
+ if (typeof migration.schemaPath !== "string") {
472
+ pushError(errors, `${runtimeLabel(runtime)} maintained prisma migration requires schemaPath`);
473
+ }
474
+ if (typeof migration.migrationsPath !== "string") {
475
+ pushError(errors, `${runtimeLabel(runtime)} maintained prisma migration requires migrationsPath`);
476
+ }
477
+ }
478
+ if (migration.ownership === "maintained" && migration.tool === "drizzle") {
479
+ if (typeof migration.schemaPath !== "string") {
480
+ pushError(errors, `${runtimeLabel(runtime)} maintained drizzle migration requires schemaPath`);
481
+ }
482
+ if (typeof migration.migrationsPath !== "string") {
483
+ pushError(errors, `${runtimeLabel(runtime)} maintained drizzle migration requires migrationsPath`);
484
+ }
485
+ }
486
+ if (migration.ownership === "maintained" && migration.tool === "sql" && typeof migration.migrationsPath !== "string") {
487
+ pushError(errors, `${runtimeLabel(runtime)} maintained sql migration requires migrationsPath`);
488
+ }
489
+ }
490
+
394
491
  /**
395
492
  * @param {ValidationError[]} errors
396
493
  * @param {RuntimeTopologyRuntime} runtime
@@ -2,6 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @typedef {import("./project-config/index.js").GeneratorBinding} GeneratorBinding
5
+ * @typedef {import("./project-config/index.js").RuntimeMigrationStrategy} RuntimeMigrationStrategy
5
6
  * @typedef {import("./project-config/index.js").RuntimeTopologyRuntime} RuntimeTopologyRuntime
6
7
  * @typedef {import("./project-config/index.js").ProjectConfig} ProjectConfig
7
8
  * @typedef {import("./project-config/index.js").ProjectConfigInfo} ProjectConfigInfo
@@ -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),