@databricks-solutions/lakebase-scm-utils 0.2.51 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +10 -4
  2. package/dist/scripts/index.cjs +2813 -2244
  3. package/dist/scripts/index.d.cts +1 -1
  4. package/dist/scripts/index.d.ts +1 -1
  5. package/dist/scripts/index.js +857 -298
  6. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2460 -2030
  7. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +508 -81
  8. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2429 -2007
  9. package/dist/scripts/lakebase/collapse-heads.cli.js +477 -58
  10. package/dist/scripts/lakebase/detect-language.cli.cjs +2441 -2016
  11. package/dist/scripts/lakebase/detect-language.cli.js +489 -67
  12. package/dist/scripts/lakebase/doctor.cli.cjs +2484 -2040
  13. package/dist/scripts/lakebase/doctor.cli.js +532 -91
  14. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  15. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  16. package/dist/scripts/lakebase/index.cjs +2810 -2205
  17. package/dist/scripts/lakebase/index.d.cts +59 -10
  18. package/dist/scripts/lakebase/index.d.ts +59 -10
  19. package/dist/scripts/lakebase/index.js +851 -256
  20. package/dist/scripts/lakebase/infra-runner.cli.cjs +2435 -2013
  21. package/dist/scripts/lakebase/infra-runner.cli.js +483 -64
  22. package/dist/scripts/lakebase/new-migration.cli.cjs +2429 -2007
  23. package/dist/scripts/lakebase/new-migration.cli.js +477 -58
  24. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  25. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  26. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2461 -2029
  27. package/dist/scripts/lakebase/schema-migrate.cli.js +509 -80
  28. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2460 -2030
  29. package/dist/scripts/lakebase/scm-claim-feature.cli.js +508 -81
  30. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2486 -2056
  31. package/dist/scripts/lakebase/scm-doctor.cli.js +537 -107
  32. package/dist/scripts/lakebase/scm-merge.cli.cjs +2443 -2021
  33. package/dist/scripts/lakebase/scm-merge.cli.js +494 -72
  34. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2464 -2034
  35. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +511 -84
  36. package/dist/scripts/util/index.cjs +2 -2
  37. package/dist/scripts/util/index.js +2 -2
  38. package/package.json +1 -1
  39. package/scripts/lakebase/adapters/efcore-adapter.ts +158 -0
  40. package/scripts/lakebase/adapters/flyway-adapter.ts +8 -5
  41. package/scripts/lakebase/client-lockfile.ts +102 -0
  42. package/scripts/lakebase/collapse-heads.cli.ts +1 -1
  43. package/scripts/lakebase/create-project.ts +25 -2
  44. package/scripts/lakebase/detect-language.cli.ts +2 -2
  45. package/scripts/lakebase/doctor.ts +29 -0
  46. package/scripts/lakebase/dotnet-project.ts +112 -0
  47. package/scripts/lakebase/index.ts +1 -0
  48. package/scripts/lakebase/migration-layout.ts +43 -2
  49. package/scripts/lakebase/new-migration.cli.ts +1 -1
  50. package/scripts/lakebase/scaffold-language.ts +23 -2
  51. package/scripts/lakebase/scaffold.ts +4 -1
  52. package/scripts/lakebase/schema-migrate-runners/efcore.ts +399 -0
  53. package/scripts/lakebase/schema-migrate-runners/flyway.ts +6 -4
  54. package/scripts/lakebase/schema-migrate.cli.ts +3 -1
  55. package/scripts/lakebase/schema-migrate.ts +24 -10
  56. package/templates/project/common/.github/workflows/merge.yml +13 -1
  57. package/templates/project/common/.github/workflows/pr.yml +25 -5
  58. package/templates/project/common/scripts/flyway-migrate.sh +54 -1
  59. package/templates/project/common/scripts/prepare-schema-diff.sh +5 -0
  60. package/templates/project/common/scripts/run-dev.sh +10 -1
  61. package/templates/project/common/scripts/run-tests.sh +6 -1
  62. package/templates/project/dotnet/.gitignore.extra +8 -0
  63. package/templates/project/dotnet/App.sln +56 -0
  64. package/templates/project/dotnet/Makefile +13 -0
  65. package/templates/project/dotnet/global.json +6 -0
  66. package/templates/project/dotnet/src/App/Data/AppDbContext.cs +12 -0
  67. package/templates/project/dotnet/src/App/Lakebase/DotEnv.cs +48 -0
  68. package/templates/project/dotnet/src/App/Lakebase/LakebaseConnection.cs +225 -0
  69. package/templates/project/dotnet/src/App/Program.cs +34 -0
  70. package/templates/project/dotnet/src/App/appsettings.json +9 -0
  71. package/templates/project/dotnet/tests/App.Tests/App.Tests.csproj +25 -0
  72. package/templates/project/dotnet/tests/App.Tests/HealthTests.cs +18 -0
  73. package/templates/project/dotnet/tests/App.Tests/OpenApiTests.cs +23 -0
  74. package/templates/project/dotnet-efcore/.config/dotnet-tools.json +13 -0
  75. package/templates/project/dotnet-efcore/src/App/App.csproj +22 -0
  76. package/templates/project/dotnet-efcore/src/App/Data/DesignTimeDbContextFactory.cs +24 -0
  77. package/templates/project/dotnet-efcore/src/App/Migrations/README.md +23 -0
  78. package/templates/project/dotnet-flyway/db/migrations/README.md +23 -0
  79. package/templates/project/dotnet-flyway/src/App/App.csproj +17 -0
@@ -11,17 +11,19 @@
11
11
 
12
12
  import * as fs from "fs";
13
13
  import * as path from "path";
14
+ import { findEfCoreProject, hasDotnetMarker, isDotnetFlywayProject } from "./dotnet-project.js";
14
15
 
15
16
  /** Languages the kit knows how to detect. `unknown` maps to the Flyway
16
17
  * defaults (the historical fallback) so callers never crash on an
17
18
  * unrecognized layout. */
18
- export type MigrationLanguage = "java" | "kotlin" | "python" | "nodejs" | "unknown";
19
+ export type MigrationLanguage = "java" | "kotlin" | "python" | "nodejs" | "dotnet" | "unknown";
19
20
 
20
21
  const MIGRATION_LANGUAGES: readonly MigrationLanguage[] = [
21
22
  "java",
22
23
  "kotlin",
23
24
  "python",
24
25
  "nodejs",
26
+ "dotnet",
25
27
  "unknown",
26
28
  ];
27
29
 
@@ -41,6 +43,9 @@ export const MIGRATION_DEFAULTS: Record<MigrationLanguage, MigrationDefaults> =
41
43
  kotlin: { path: "src/main/resources/db/migration", pattern: /^V\d+.*\.sql$/i, glob: "*.sql" },
42
44
  python: { path: "alembic/versions", pattern: /^[0-9a-f][\w]*.*\.py$/i, glob: "*.py" },
43
45
  nodejs: { path: "migrations", pattern: /^\d+.*\.(js|ts)$/i, glob: "*.{js,ts}" },
46
+ // .NET + Flyway; EF Core projects get their Migrations/ dir from
47
+ // resolveMigrationLayout (see DOTNET_EFCORE_DEFAULTS).
48
+ dotnet: { path: "db/migrations", pattern: /^V\d+.*\.sql$/i, glob: "*.sql" },
44
49
  unknown: { path: "src/main/resources/db/migration", pattern: /^V\d+.*\.sql$/i, glob: "*.sql" },
45
50
  };
46
51
 
@@ -70,6 +75,11 @@ export function detectLanguageAt(dir: string): MigrationLanguage {
70
75
  ) {
71
76
  return "python";
72
77
  }
78
+ // .NET before package.json: an ASP.NET repo may carry a package.json for
79
+ // front-end tooling, but a *.sln / *.csproj at the root is unambiguous.
80
+ if (hasDotnetMarker(dir)) {
81
+ return "dotnet";
82
+ }
73
83
  if (fs.existsSync(path.join(dir, "package.json"))) {
74
84
  return "nodejs";
75
85
  }
@@ -144,6 +154,37 @@ export function compileMigrationPattern(src?: string): RegExp | undefined {
144
154
  }
145
155
  }
146
156
 
157
+ /** EF Core: Migrations/<timestamp>_<Name>.cs, excluding *.Designer.cs and the model snapshot. */
158
+ export const DOTNET_EFCORE_DEFAULTS: MigrationDefaults = {
159
+ path: "Migrations",
160
+ pattern: /^\d{14}_(?!.*\.Designer\.cs$).+\.cs$/i,
161
+ glob: "*.cs",
162
+ };
163
+
164
+ // IDE callers resolve the layout on every refresh; .NET resolution scans and
165
+ // reads *.csproj files, so cache it briefly per project root.
166
+ const DOTNET_LAYOUT_TTL_MS = 5000;
167
+ const dotnetLayoutCache = new Map<string, { at: number; value: MigrationDefaults }>();
168
+
169
+ /** .NET defaults depend on the migration tool: Flyway (db/migrations) or EF Core. */
170
+ function dotnetDefaults(projectDir?: string): MigrationDefaults {
171
+ if (!projectDir) return MIGRATION_DEFAULTS.dotnet;
172
+ const key = path.resolve(projectDir);
173
+ const hit = dotnetLayoutCache.get(key);
174
+ if (hit && Date.now() - hit.at < DOTNET_LAYOUT_TTL_MS) return hit.value;
175
+
176
+ let value = MIGRATION_DEFAULTS.dotnet;
177
+ if (!isDotnetFlywayProject(projectDir)) {
178
+ const ef = findEfCoreProject(projectDir);
179
+ if (ef) {
180
+ const rel = path.relative(projectDir, ef.migrationsDir).split(path.sep).join("/");
181
+ value = { ...DOTNET_EFCORE_DEFAULTS, path: rel };
182
+ }
183
+ }
184
+ dotnetLayoutCache.set(key, { at: Date.now(), value });
185
+ return value;
186
+ }
187
+
147
188
  export interface ResolveMigrationLayoutArgs {
148
189
  /** Project root. */
149
190
  projectDir?: string;
@@ -175,7 +216,7 @@ export interface MigrationLayout {
175
216
  export function resolveMigrationLayout(args: ResolveMigrationLayoutArgs): MigrationLayout {
176
217
  const configuredMigrationPath = (args.migrationPath ?? "").trim();
177
218
  const language = resolveMigrationLanguage(args.projectDir, configuredMigrationPath, args.language);
178
- const defaults = MIGRATION_DEFAULTS[language];
219
+ const defaults = language === "dotnet" ? dotnetDefaults(args.projectDir) : MIGRATION_DEFAULTS[language];
179
220
  return {
180
221
  language,
181
222
  migrationPath: configuredMigrationPath || defaults.path,
@@ -92,7 +92,7 @@ Usage:
92
92
  Flags:
93
93
  --name, -m <text> Human description; slugified into the filename (required)
94
94
  --project-dir <dir> Project root (default: cwd)
95
- --language <lang> java | kotlin | python | nodejs (default: auto-detect)
95
+ --language <lang> java | kotlin | python | nodejs | dotnet (default: auto-detect)
96
96
  --autogenerate Alembic only: diff models vs the branch DB to populate
97
97
  the body (requires --instance + --branch)
98
98
  --instance <id> Lakebase project id (only with --autogenerate)
@@ -1,6 +1,8 @@
1
1
  // Language-specific scaffold router. Mirrors ScaffoldService.deployLanguageProject:
2
2
  // - java / kotlin → Spring Initializr (with bundled fallback)
3
3
  // - python / nodejs → static template copy with {{PROJECT_NAME}} substitution
4
+ // - dotnet → static template copy (dotnet/) + a migration-tool
5
+ // overlay (dotnet-efcore/ or dotnet-flyway/)
4
6
 
5
7
  import * as fs from "node:fs";
6
8
  import * as path from "node:path";
@@ -13,7 +15,10 @@ import {
13
15
  ScaffoldReportFn,
14
16
  } from "./spring-initializr.js";
15
17
 
16
- export type ProjectLanguage = "java" | "kotlin" | "python" | "nodejs";
18
+ export type ProjectLanguage = "java" | "kotlin" | "python" | "nodejs" | "dotnet";
19
+
20
+ /** Migration tool for a .NET project. EF Core is the default. */
21
+ export type DotnetMigrationTool = "efcore" | "flyway";
17
22
 
18
23
  /**
19
24
  * The frontend the project ships. "react" scaffolds the first-class SPA client
@@ -28,6 +33,8 @@ export interface DeployLanguageProjectArgs {
28
33
  targetDir: string;
29
34
  language: ProjectLanguage;
30
35
  projectName?: string;
36
+ /** .NET only: which migration tool the scaffold is set up for (default "efcore"). */
37
+ migrationTool?: DotnetMigrationTool;
31
38
  /** Override templates dir (tests). */
32
39
  templatesDir?: string;
33
40
  /** Override Initializr client (tests). */
@@ -66,8 +73,22 @@ export async function deployLanguageProject(args: DeployLanguageProjectArgs): Pr
66
73
  return;
67
74
  }
68
75
 
69
- // Python / Node.js – static template copy with placeholder substitution.
76
+ // Python / Node.js / .NET – static template copy with placeholder substitution.
70
77
  const templatesDir = args.templatesDir ?? findTemplatesDir();
78
+ if (args.language === "dotnet") {
79
+ const tool = args.migrationTool ?? "efcore";
80
+ if (tool !== "efcore" && tool !== "flyway") {
81
+ throw new Error(`Unsupported .NET migration tool: ${String(tool)} (expected efcore or flyway)`);
82
+ }
83
+ const base = path.join(templatesDir, "dotnet");
84
+ const overlay = path.join(templatesDir, `dotnet-${tool}`);
85
+ if (!fs.existsSync(base) || !fs.existsSync(overlay)) {
86
+ throw new Error(`No template found for language: dotnet (${tool})`);
87
+ }
88
+ copyDirSubstituted(base, args.targetDir, { projectName: args.projectName });
89
+ copyDirSubstituted(overlay, args.targetDir, { projectName: args.projectName });
90
+ return;
91
+ }
71
92
  const langSrc = path.join(templatesDir, args.language);
72
93
  if (!fs.existsSync(langSrc)) {
73
94
  throw new Error(`No template found for language: ${args.language}`);
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { deployLanguageProject } from "./scaffold-language.js";
14
- import type { ProjectLanguage, ClientFramework } from "./scaffold-language.js";
14
+ import type { ProjectLanguage, ClientFramework, DotnetMigrationTool } from "./scaffold-language.js";
15
15
  import { copyDirSubstituted } from "../util/copy-dir-substituted.js";
16
16
  import type { SpringInitializrClient } from "./spring-initializr.js";
17
17
 
@@ -635,6 +635,8 @@ export interface ScaffoldStaticAllArgs extends ScaffoldOptions {
635
635
  databricksHost?: string;
636
636
  lakebaseProjectId?: string;
637
637
  language?: ProjectLanguage;
638
+ /** .NET only: "efcore" (default) or "flyway". Ignored for other languages. */
639
+ migrationTool?: DotnetMigrationTool;
638
640
  runnerType?: RunnerType;
639
641
  report?: ScaffoldReportFn;
640
642
  /**
@@ -766,6 +768,7 @@ export async function scaffoldAll(args: ScaffoldAllArgs): Promise<ScaffoldStatic
766
768
  targetDir: args.targetDir,
767
769
  language,
768
770
  projectName,
771
+ migrationTool: args.migrationTool,
769
772
  templatesDir: args.templatesDir,
770
773
  initializrClient: args.initializrClient,
771
774
  report,
@@ -0,0 +1,399 @@
1
+ // EF Core runner for .NET projects.
2
+ //
3
+ // Shells to the `dotnet ef` CLI (pinned per project in
4
+ // .config/dotnet-tools.json and restored on first use). Output is read with
5
+ // `--json --prefix-output`, where the JSON payload is the set of lines
6
+ // prefixed `data:`, which gives a stable parse target independent of build
7
+ // and logging noise.
8
+ //
9
+ // Connection: the target branch DSN is passed in the child's DATABASE_URL
10
+ // environment variable, never on the command line, so the credential does
11
+ // not appear in `ps`. The project's IDesignTimeDbContextFactory must read
12
+ // DATABASE_URL (the kit's template does). Projects that cannot can set
13
+ // LAKEBASE_EFCORE_CONNECTION_ARG=1 to pass `--connection` instead (the
14
+ // password is then visible to other local users via `ps`).
15
+ //
16
+ // History lives in "__EFMigrationsHistory" ("MigrationId" = <timestamp>_<Name>).
17
+ // The kit-facing version is the 14-digit timestamp prefix, matching the
18
+ // timestamp versions used for Flyway, Alembic, and Knex.
19
+
20
+ import { spawn } from "node:child_process";
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { Client } from "pg";
24
+ import { EF_DESIGN_PACKAGE, findEfCoreProject, type EfCoreProject } from "../dotnet-project.js";
25
+ import { connectionApplicationName } from "../get-connection.js";
26
+ import {
27
+ SchemaMigrationError,
28
+ type ApplySchemaMigrationsResult,
29
+ type AppliedSchemaMigration,
30
+ type PendingSchemaMigration,
31
+ type RollbackSchemaMigrationResult,
32
+ type SchemaMigrationStatusResult,
33
+ } from "../schema-migrate.js";
34
+
35
+ export interface EfCoreCtx {
36
+ projectDir: string;
37
+ dsn: string;
38
+ /**
39
+ * Reads applied migration ids from "__EFMigrationsHistory" (ascending), or
40
+ * null when the table does not exist. Defaults to a direct query; tests
41
+ * inject a fake.
42
+ */
43
+ readHistory?: (dsn: string) => Promise<string[] | null>;
44
+ }
45
+
46
+ /** One migration as reported by `dotnet ef migrations list --json`. */
47
+ export interface EfMigrationEntry {
48
+ id: string;
49
+ name: string;
50
+ /** true/false with a connection; null with --no-connect. */
51
+ applied: boolean | null;
52
+ }
53
+
54
+ const MIGRATION_ID = /^(\d{14})_(.+)$/;
55
+
56
+ /** Kit version for an EF migration id: its timestamp prefix. */
57
+ export function efVersion(id: string): string {
58
+ return MIGRATION_ID.exec(id)?.[1] ?? id;
59
+ }
60
+
61
+ /** Human description for an EF migration id ("AddMemberPhone" -> "Add Member Phone"). */
62
+ export function efDescription(id: string): string {
63
+ const name = MIGRATION_ID.exec(id)?.[2] ?? id;
64
+ return name.replace(/_/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2");
65
+ }
66
+
67
+ /** Convert a postgresql:// DSN into an Npgsql connection string. */
68
+ export function dsnToNpgsql(dsn: string): string {
69
+ const u = new URL(dsn);
70
+ const parts: string[] = [
71
+ `Host=${u.hostname}`,
72
+ `Port=${u.port || "5432"}`,
73
+ `Database=${decodeURIComponent(u.pathname.replace(/^\//, "")) || "databricks_postgres"}`,
74
+ ];
75
+ if (u.username) parts.push(`Username=${decodeURIComponent(u.username)}`);
76
+ if (u.password) parts.push(`Password=${decodeURIComponent(u.password)}`);
77
+ const sslmode = (u.searchParams.get("sslmode") ?? "require").toLowerCase();
78
+ const ssl: Record<string, string> = {
79
+ disable: "Disable",
80
+ allow: "Allow",
81
+ prefer: "Prefer",
82
+ require: "Require",
83
+ "verify-ca": "VerifyCA",
84
+ "verify-full": "VerifyFull",
85
+ };
86
+ parts.push(`SSL Mode=${ssl[sslmode] ?? "Require"}`);
87
+ const app = u.searchParams.get("application_name");
88
+ if (app) parts.push(`Application Name=${app}`);
89
+ return parts.map((p) => p.replace(/;/g, "")).join(";");
90
+ }
91
+
92
+ /** Extract the JSON payload from `--prefix-output` output (lines starting `data:`). */
93
+ export function parseEfJson<T>(stdout: string): T {
94
+ const body = stdout
95
+ .split(/\r?\n/)
96
+ .filter((l) => l.startsWith("data:"))
97
+ .map((l) => l.slice("data:".length))
98
+ .join("\n")
99
+ .trim();
100
+ if (!body) {
101
+ throw new SchemaMigrationError(`dotnet ef returned no JSON payload: ${stdout.slice(0, 400)}`);
102
+ }
103
+ try {
104
+ return JSON.parse(body) as T;
105
+ } catch (err) {
106
+ throw new SchemaMigrationError(
107
+ `dotnet ef JSON parse failed: ${err instanceof Error ? err.message : String(err)}.\n` +
108
+ `Body (first 400 chars): ${body.slice(0, 400)}`
109
+ );
110
+ }
111
+ }
112
+
113
+ function requireProject(projectDir: string): EfCoreProject {
114
+ const project = findEfCoreProject(projectDir);
115
+ if (!project) {
116
+ throw new SchemaMigrationError(
117
+ `No EF Core project found under ${projectDir}. Expected a *.csproj that references ${EF_DESIGN_PACKAGE}.`
118
+ );
119
+ }
120
+ return project;
121
+ }
122
+
123
+ function spawnDotnet(
124
+ projectDir: string,
125
+ args: string[],
126
+ env: NodeJS.ProcessEnv
127
+ ): Promise<{ stdout: string; stderr: string }> {
128
+ return new Promise((resolve, reject) => {
129
+ const child = spawn("dotnet", args, {
130
+ cwd: projectDir,
131
+ env: { ...process.env, DOTNET_NOLOGO: "1", DOTNET_CLI_TELEMETRY_OPTOUT: "1", ...env },
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ });
134
+ let stdout = "";
135
+ let stderr = "";
136
+ child.stdout.on("data", (chunk: Buffer) => {
137
+ stdout += chunk.toString("utf8");
138
+ });
139
+ child.stderr.on("data", (chunk: Buffer) => {
140
+ stderr += chunk.toString("utf8");
141
+ });
142
+ child.on("error", (err) => {
143
+ reject(
144
+ new SchemaMigrationError(
145
+ `Could not spawn dotnet. Is the .NET SDK installed and on PATH? ${err.message}`,
146
+ err
147
+ )
148
+ );
149
+ });
150
+ child.on("close", (code) => {
151
+ if (code === 0) {
152
+ resolve({ stdout, stderr });
153
+ } else {
154
+ reject(
155
+ new SchemaMigrationError(
156
+ `dotnet ${args.join(" ")} exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`
157
+ )
158
+ );
159
+ }
160
+ });
161
+ });
162
+ }
163
+
164
+ const restored = new Set<string>();
165
+
166
+ /**
167
+ * Once per project per process: `dotnet tool restore` (when a tool manifest
168
+ * exists) and `dotnet restore` of the EF project. `dotnet ef` reads project
169
+ * metadata before it builds, which fails on a fresh clone without restored
170
+ * assets.
171
+ */
172
+ async function ensureRestored(projectDir: string, csproj: string): Promise<void> {
173
+ if (restored.has(projectDir)) return;
174
+ if (fs.existsSync(path.join(projectDir, ".config", "dotnet-tools.json"))) {
175
+ await spawnDotnet(projectDir, ["tool", "restore"], {});
176
+ }
177
+ await spawnDotnet(projectDir, ["restore", csproj], {});
178
+ restored.add(projectDir);
179
+ }
180
+
181
+ /**
182
+ * Run `dotnet ef <args>` against the project's EF Core project. `noBuild`
183
+ * skips the implicit build, for follow-up calls in the same operation.
184
+ */
185
+ async function runEf(
186
+ projectDir: string,
187
+ args: string[],
188
+ dsn?: string,
189
+ opts: { noBuild?: boolean } = {}
190
+ ): Promise<{ stdout: string; stderr: string }> {
191
+ const project = requireProject(projectDir);
192
+ const csproj = path.relative(projectDir, project.csproj) || project.csproj;
193
+ await ensureRestored(projectDir, csproj);
194
+ const full = ["ef", ...args, "--project", csproj, "--prefix-output"];
195
+ if (opts.noBuild) full.push("--no-build");
196
+ const env: NodeJS.ProcessEnv = {};
197
+ if (dsn) {
198
+ env.DATABASE_URL = dsn;
199
+ if (process.env.LAKEBASE_EFCORE_CONNECTION_ARG === "1") full.push("--connection", dsnToNpgsql(dsn));
200
+ }
201
+ return spawnDotnet(projectDir, full, env);
202
+ }
203
+
204
+ /**
205
+ * List migrations with their applied state on the target database. EF reports
206
+ * `applied: null` when it cannot reach the database (exit code 0), so that is
207
+ * an error here rather than "nothing applied".
208
+ */
209
+ export async function listEfMigrations(
210
+ ctx: EfCoreCtx,
211
+ opts: { noBuild?: boolean } = {}
212
+ ): Promise<EfMigrationEntry[]> {
213
+ const { stdout, stderr } = await runEf(ctx.projectDir, ["migrations", "list", "--json"], ctx.dsn, opts);
214
+ const entries = parseEfJson<EfMigrationEntry[]>(stdout);
215
+ if (ctx.dsn && entries.some((e) => e.applied === null)) {
216
+ const warning = stdout
217
+ .split(/\r?\n/)
218
+ .filter((l) => /^(warn|error):/.test(l))
219
+ .join("\n");
220
+ throw new SchemaMigrationError(
221
+ `dotnet ef could not read migration state from the database (check connectivity and credentials).\n${warning || stderr}`.trim()
222
+ );
223
+ }
224
+ return entries;
225
+ }
226
+
227
+ /** Applied migration ids from "__EFMigrationsHistory", ascending; null when the table is absent. */
228
+ export async function readEfHistory(dsn: string): Promise<string[] | null> {
229
+ const client = new Client({ connectionString: dsn, application_name: connectionApplicationName() });
230
+ await client.connect();
231
+ try {
232
+ const exists = await client.query(`SELECT to_regclass('"__EFMigrationsHistory"') IS NOT NULL AS present`);
233
+ if (!exists.rows[0]?.present) return null;
234
+ const r = await client.query(`SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId"`);
235
+ return r.rows.map((row: { MigrationId: string }) => row.MigrationId);
236
+ } finally {
237
+ await client.end();
238
+ }
239
+ }
240
+
241
+ function toApplied(entries: EfMigrationEntry[]): AppliedSchemaMigration[] {
242
+ return entries.map((e) => ({ version: efVersion(e.id), description: efDescription(e.id) }));
243
+ }
244
+
245
+ /**
246
+ * `current` comes from "__EFMigrationsHistory" itself, not from
247
+ * `migrations list` (which only knows migrations compiled into the project),
248
+ * so a database that is ahead of the code reports its real head and the
249
+ * kit's orphan check can see it.
250
+ */
251
+ export async function statusEfcore(ctx: EfCoreCtx): Promise<SchemaMigrationStatusResult> {
252
+ const entries = await listEfMigrations(ctx);
253
+ const history = await (ctx.readHistory ?? readEfHistory)(ctx.dsn);
254
+ const current = history && history.length > 0 ? efVersion(history[history.length - 1]) : undefined;
255
+ const pending: PendingSchemaMigration[] = entries
256
+ .filter((e) => e.applied !== true)
257
+ .map((e) => ({ version: efVersion(e.id), filename: `${e.id}.cs`, description: efDescription(e.id) }));
258
+ return { current, pending, tool: "efcore" };
259
+ }
260
+
261
+ export async function applyEfcore(ctx: EfCoreCtx): Promise<ApplySchemaMigrationsResult> {
262
+ const before = new Set((await listEfMigrations(ctx)).filter((e) => e.applied).map((e) => e.id));
263
+ await runEf(ctx.projectDir, ["database", "update"], ctx.dsn, { noBuild: true });
264
+ const after = await listEfMigrations(ctx, { noBuild: true });
265
+ const applied = toApplied(after.filter((e) => e.applied && !before.has(e.id)));
266
+ return { applied, alreadyAtLatest: applied.length === 0, tool: "efcore" };
267
+ }
268
+
269
+ /**
270
+ * Resolve a rollback target to an EF migration id (or "0" for an empty
271
+ * database). Accepts "0", a relative step ("-1", "-2"), a full migration id,
272
+ * its timestamp version, or its name.
273
+ */
274
+ export function resolveEfTarget(target: string, applied: EfMigrationEntry[]): string {
275
+ const t = target.trim();
276
+ if (t === "0") return "0";
277
+ const rel = /^-(\d+)$/.exec(t);
278
+ if (rel) {
279
+ const steps = Number(rel[1]);
280
+ if (steps < 1) throw new SchemaMigrationError(`Invalid rollback step: ${target}`);
281
+ const index = applied.length - 1 - steps;
282
+ if (index < -1) {
283
+ throw new SchemaMigrationError(`Cannot roll back ${steps} migration(s); only ${applied.length} applied.`);
284
+ }
285
+ return index === -1 ? "0" : applied[index].id;
286
+ }
287
+ const match = applied.find((e) => e.id === t || efVersion(e.id) === t || e.name === t);
288
+ if (!match) throw new SchemaMigrationError(`Rollback target '${target}' is not an applied migration.`);
289
+ return match.id;
290
+ }
291
+
292
+ export async function rollbackEfcore(ctx: EfCoreCtx & { target: string }): Promise<RollbackSchemaMigrationResult> {
293
+ const beforeEntries = (await listEfMigrations(ctx)).filter((e) => e.applied);
294
+ const targetId = resolveEfTarget(ctx.target, beforeEntries);
295
+ await runEf(ctx.projectDir, ["database", "update", targetId], ctx.dsn, { noBuild: true });
296
+ const stillApplied = new Set((await listEfMigrations(ctx, { noBuild: true })).filter((e) => e.applied).map((e) => e.id));
297
+ return { rolledBack: toApplied(beforeEntries.filter((e) => !stillApplied.has(e.id)).reverse()), tool: "efcore" };
298
+ }
299
+
300
+ /** C# identifier for `migrations add` ("add member phone" -> "AddMemberPhone"). */
301
+ export function efMigrationName(slug: string): string {
302
+ const name = slug
303
+ .split(/[^A-Za-z0-9]+/)
304
+ .filter(Boolean)
305
+ .map((w) => w[0].toUpperCase() + w.slice(1))
306
+ .join("");
307
+ if (!name) throw new SchemaMigrationError(`Cannot derive a migration name from '${slug}'.`);
308
+ return /^[0-9]/.test(name) ? `M${name}` : name;
309
+ }
310
+
311
+ /**
312
+ * `dotnet ef migrations add <Name>`: EF generates the migration from the
313
+ * model diff (the EF equivalent of Alembic autogenerate). No DB needed.
314
+ */
315
+ export async function createEfcoreMigration(opts: {
316
+ projectDir: string;
317
+ slug: string;
318
+ }): Promise<{ id: string; path: string }> {
319
+ const project = requireProject(opts.projectDir);
320
+ const name = efMigrationName(opts.slug);
321
+ const existing = new Set(fs.existsSync(project.migrationsDir) ? fs.readdirSync(project.migrationsDir) : []);
322
+ await runEf(opts.projectDir, ["migrations", "add", name]);
323
+ const created = fs
324
+ .readdirSync(project.migrationsDir)
325
+ .filter((f) => !existing.has(f) && MIGRATION_ID.test(f.replace(/\.cs$/, "")) && !f.endsWith(".Designer.cs"));
326
+ if (created.length !== 1) {
327
+ throw new SchemaMigrationError(`Expected one new migration file for ${name}, found: ${created.join(", ") || "none"}`);
328
+ }
329
+ return { id: created[0].replace(/\.cs$/, ""), path: path.join(project.migrationsDir, created[0]) };
330
+ }
331
+
332
+ /** Local EF migration ids in apply order (from Migrations/*.cs, no build). */
333
+ export function localEfMigrationIds(projectDir: string): string[] {
334
+ const project = findEfCoreProject(projectDir);
335
+ if (!project || !fs.existsSync(project.migrationsDir)) return [];
336
+ return fs
337
+ .readdirSync(project.migrationsDir)
338
+ .filter((f) => f.endsWith(".cs") && !f.endsWith(".Designer.cs") && MIGRATION_ID.test(f.slice(0, -3)))
339
+ .map((f) => f.slice(0, -3))
340
+ .sort();
341
+ }
342
+
343
+ /** EF Core version recorded in "ProductVersion" when stamping. */
344
+ function efProductVersion(project: EfCoreProject): string {
345
+ try {
346
+ const csproj = fs.readFileSync(project.csproj, "utf-8");
347
+ const m = new RegExp(`${EF_DESIGN_PACKAGE.replace(/\./g, "\\.")}"\\s+Version="([^"]+)"`).exec(csproj);
348
+ if (m) return m[1];
349
+ } catch {
350
+ /* fall through */
351
+ }
352
+ return "unknown";
353
+ }
354
+
355
+ /**
356
+ * Set "__EFMigrationsHistory" to exactly the local migrations up to
357
+ * `revision` ("head" = all local) WITHOUT running them: rows for those ids are
358
+ * added and every other row (newer migrations, or orphans whose files are
359
+ * gone) is removed, matching Alembic's stamp. Used to adopt a database whose
360
+ * schema already matches, and by reconcile for a database ahead of code. EF
361
+ * has no native stamp command, so this writes the table in one transaction.
362
+ */
363
+ export async function stampEfcore(ctx: EfCoreCtx & { revision: string }): Promise<string> {
364
+ const project = requireProject(ctx.projectDir);
365
+ const ids = localEfMigrationIds(ctx.projectDir);
366
+ if (ids.length === 0) throw new SchemaMigrationError("No local EF Core migrations to stamp.");
367
+ const rev = ctx.revision.trim();
368
+ const end = rev === "head" ? ids.length - 1 : ids.findIndex((id) => id === rev || efVersion(id) === rev);
369
+ if (end < 0) throw new SchemaMigrationError(`Stamp revision '${ctx.revision}' is not a local migration.`);
370
+ const toStamp = ids.slice(0, end + 1);
371
+ const productVersion = efProductVersion(project);
372
+
373
+ const client = new Client({ connectionString: ctx.dsn, application_name: connectionApplicationName() });
374
+ await client.connect();
375
+ try {
376
+ await client.query("BEGIN");
377
+ await client.query(
378
+ `CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
379
+ "MigrationId" character varying(150) NOT NULL,
380
+ "ProductVersion" character varying(32) NOT NULL,
381
+ CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId"))`
382
+ );
383
+ await client.query(`DELETE FROM "__EFMigrationsHistory" WHERE NOT ("MigrationId" = ANY($1::text[]))`, [toStamp]);
384
+ for (const id of toStamp) {
385
+ await client.query(
386
+ `INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ($1, $2)
387
+ ON CONFLICT ("MigrationId") DO NOTHING`,
388
+ [id, productVersion]
389
+ );
390
+ }
391
+ await client.query("COMMIT");
392
+ } catch (err) {
393
+ await client.query("ROLLBACK").catch(() => undefined);
394
+ throw err;
395
+ } finally {
396
+ await client.end();
397
+ }
398
+ return efVersion(toStamp[toStamp.length - 1]);
399
+ }
@@ -1,9 +1,10 @@
1
- // Flyway runner for Java + Kotlin projects (part of the
2
- // primitives lift).
1
+ // Flyway runner for Java + Kotlin projects, and .NET projects that use
2
+ // Flyway (part of the primitives lift).
3
3
  //
4
4
  // Shells to the standalone Flyway Community Edition CLI in JSON output
5
5
  // mode. The runner reads the project's migration files from
6
- // `src/main/resources/db/migration/` (Flyway's standard layout). It
6
+ // `src/main/resources/db/migration/` (Flyway's standard Maven layout) or,
7
+ // for .NET, `db/migrations/` (see flywayMigrationsDir). It
7
8
  // passes connection info through FLYWAY_URL / FLYWAY_USER /
8
9
  // FLYWAY_PASSWORD env vars rather than command-line flags so the
9
10
  // secret never appears in `ps`.
@@ -25,6 +26,7 @@
25
26
 
26
27
  import { spawn } from "node:child_process";
27
28
  import * as path from "node:path";
29
+ import { flywayMigrationsDir } from "../dotnet-project.js";
28
30
  import {
29
31
  SchemaMigrationError,
30
32
  type ApplySchemaMigrationsResult,
@@ -52,7 +54,7 @@ function dsnToFlywayEnv(dsn: string): { url: string; user: string; password: str
52
54
  }
53
55
 
54
56
  function migrationsLocation(projectDir: string): string {
55
- return `filesystem:${path.join(projectDir, "src", "main", "resources", "db", "migration")}`;
57
+ return `filesystem:${flywayMigrationsDir(projectDir)}`;
56
58
  }
57
59
 
58
60
  interface FlywayRun {
@@ -94,7 +94,7 @@ Common flags (for apply, rollback, status):
94
94
  --instance <id> Lakebase project id (required)
95
95
  --branch <name> Branch to migrate against (required)
96
96
  --project-dir <dir> Project root (default: cwd)
97
- --language <lang> java | kotlin | python | nodejs (default: auto-detect)
97
+ --language <lang> java | kotlin | python | nodejs | dotnet (default: auto-detect)
98
98
  --database <db> Database name (default: $PGDATABASE or "databricks_postgres")
99
99
  --endpoint <name> Endpoint identifier on the branch (default: "primary")
100
100
 
@@ -115,6 +115,8 @@ Language support today:
115
115
  python (alembic) Full: apply, rollback, status, list
116
116
  java, kotlin Full: apply, status, list (rollback unsupported in Flyway Community Edition)
117
117
  nodejs (knex) Full: apply, rollback, status, list
118
+ dotnet (efcore) Full: apply, rollback, status, list (stamp via the adapter)
119
+ dotnet (flyway) Full: apply, status, list (db/migrations/; rollback unsupported)
118
120
  `;
119
121
  }
120
122