@databricks-solutions/lakebase-scm-utils 0.2.50 → 0.3.0

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 (78) hide show
  1. package/dist/scripts/index.cjs +2636 -2124
  2. package/dist/scripts/index.d.cts +1 -1
  3. package/dist/scripts/index.d.ts +1 -1
  4. package/dist/scripts/index.js +680 -178
  5. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2460 -2030
  6. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +508 -81
  7. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2429 -2007
  8. package/dist/scripts/lakebase/collapse-heads.cli.js +477 -58
  9. package/dist/scripts/lakebase/detect-language.cli.cjs +2441 -2016
  10. package/dist/scripts/lakebase/detect-language.cli.js +489 -67
  11. package/dist/scripts/lakebase/doctor.cli.cjs +2484 -2040
  12. package/dist/scripts/lakebase/doctor.cli.js +532 -91
  13. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  14. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  15. package/dist/scripts/lakebase/index.cjs +2633 -2121
  16. package/dist/scripts/lakebase/index.d.cts +59 -10
  17. package/dist/scripts/lakebase/index.d.ts +59 -10
  18. package/dist/scripts/lakebase/index.js +674 -172
  19. package/dist/scripts/lakebase/infra-runner.cli.cjs +2435 -2013
  20. package/dist/scripts/lakebase/infra-runner.cli.js +483 -64
  21. package/dist/scripts/lakebase/new-migration.cli.cjs +2429 -2007
  22. package/dist/scripts/lakebase/new-migration.cli.js +477 -58
  23. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  25. package/dist/scripts/lakebase/schema-migrate.cli.cjs +2461 -2029
  26. package/dist/scripts/lakebase/schema-migrate.cli.js +509 -80
  27. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2460 -2030
  28. package/dist/scripts/lakebase/scm-claim-feature.cli.js +508 -81
  29. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2486 -2056
  30. package/dist/scripts/lakebase/scm-doctor.cli.js +537 -107
  31. package/dist/scripts/lakebase/scm-merge.cli.cjs +2443 -2021
  32. package/dist/scripts/lakebase/scm-merge.cli.js +494 -72
  33. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2464 -2034
  34. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +511 -84
  35. package/dist/scripts/util/index.cjs +2 -2
  36. package/dist/scripts/util/index.js +2 -2
  37. package/package.json +1 -1
  38. package/scripts/lakebase/adapters/efcore-adapter.ts +158 -0
  39. package/scripts/lakebase/adapters/flyway-adapter.ts +8 -5
  40. package/scripts/lakebase/collapse-heads.cli.ts +1 -1
  41. package/scripts/lakebase/create-project.ts +14 -12
  42. package/scripts/lakebase/detect-language.cli.ts +2 -2
  43. package/scripts/lakebase/doctor.ts +29 -0
  44. package/scripts/lakebase/dotnet-project.ts +112 -0
  45. package/scripts/lakebase/index.ts +1 -0
  46. package/scripts/lakebase/migration-layout.ts +43 -2
  47. package/scripts/lakebase/new-migration.cli.ts +1 -1
  48. package/scripts/lakebase/scaffold-language.ts +23 -2
  49. package/scripts/lakebase/scaffold.ts +4 -1
  50. package/scripts/lakebase/schema-migrate-runners/efcore.ts +399 -0
  51. package/scripts/lakebase/schema-migrate-runners/flyway.ts +6 -4
  52. package/scripts/lakebase/schema-migrate.cli.ts +3 -1
  53. package/scripts/lakebase/schema-migrate.ts +24 -10
  54. package/templates/project/common/.github/workflows/merge.yml +13 -1
  55. package/templates/project/common/.github/workflows/pr.yml +25 -5
  56. package/templates/project/common/.gitignore.base +7 -0
  57. package/templates/project/common/scripts/flyway-migrate.sh +54 -1
  58. package/templates/project/common/scripts/prepare-schema-diff.sh +5 -0
  59. package/templates/project/common/scripts/run-dev.sh +10 -1
  60. package/templates/project/common/scripts/run-tests.sh +6 -1
  61. package/templates/project/dotnet/.gitignore.extra +8 -0
  62. package/templates/project/dotnet/App.sln +56 -0
  63. package/templates/project/dotnet/Makefile +13 -0
  64. package/templates/project/dotnet/global.json +6 -0
  65. package/templates/project/dotnet/src/App/Data/AppDbContext.cs +12 -0
  66. package/templates/project/dotnet/src/App/Lakebase/DotEnv.cs +48 -0
  67. package/templates/project/dotnet/src/App/Lakebase/LakebaseConnection.cs +225 -0
  68. package/templates/project/dotnet/src/App/Program.cs +34 -0
  69. package/templates/project/dotnet/src/App/appsettings.json +9 -0
  70. package/templates/project/dotnet/tests/App.Tests/App.Tests.csproj +25 -0
  71. package/templates/project/dotnet/tests/App.Tests/HealthTests.cs +18 -0
  72. package/templates/project/dotnet/tests/App.Tests/OpenApiTests.cs +23 -0
  73. package/templates/project/dotnet-efcore/.config/dotnet-tools.json +13 -0
  74. package/templates/project/dotnet-efcore/src/App/App.csproj +22 -0
  75. package/templates/project/dotnet-efcore/src/App/Data/DesignTimeDbContextFactory.cs +24 -0
  76. package/templates/project/dotnet-efcore/src/App/Migrations/README.md +23 -0
  77. package/templates/project/dotnet-flyway/db/migrations/README.md +23 -0
  78. package/templates/project/dotnet-flyway/src/App/App.csproj +17 -0
@@ -102,6 +102,18 @@ jobs:
102
102
  fi
103
103
  npm install
104
104
 
105
+ - name: Set up .NET
106
+ if: steps.detect-lang.outputs.lang == 'dotnet'
107
+ uses: actions/setup-dotnet@v4
108
+ with:
109
+ global-json-file: global.json
110
+
111
+ - name: Restore .NET packages and tools
112
+ if: steps.detect-lang.outputs.lang == 'dotnet'
113
+ run: |
114
+ dotnet restore
115
+ if [ -f .config/dotnet-tools.json ]; then dotnet tool restore; fi
116
+
105
117
  # Install frontend dependencies when a `client/` workspace is present
106
118
  # (Python+React layouts etc.). Gated on client/package.json so projects
107
119
  # without a frontend skip this transparently.
@@ -301,10 +313,12 @@ jobs:
301
313
  BODY="⚠️ **No Lakebase CI branch** was created. This run is not using a dedicated database branch. Open the [workflow run](${RUN_URL}), expand **Create CI database branch (Lakebase)** and check the step **Summary** (or log) for the reason and how to fix it."
302
314
  gh pr comment "$PR" --body "$BODY" 2>/dev/null || true
303
315
 
304
- # Install Flyway CLI for Java/Kotlin projects only. The substrate's
305
- # migrate runner spawns the standalone `flyway` binary; non-Java
306
- # projects don't need it. Runtime-gated on pom.xml so the YAML is
307
- # one shape for every language.
316
+ # Install Flyway CLI for Java/Kotlin projects and .NET projects that use
317
+ # Flyway (db/migrations/). The substrate's migrate runner spawns the
318
+ # standalone `flyway` binary; other projects don't need it. Runtime-gated
319
+ # on pom.xml / any file under db/migrations/ (what the adapter checks; a
320
+ # fresh scaffold has only its README there) so the YAML is one shape for
321
+ # every language.
308
322
  #
309
323
  # Resolution order:
310
324
  # 1. `flyway` already on PATH (self-hosted runner with brew /
@@ -315,7 +329,7 @@ jobs:
315
329
  # 3. Default https://repo1.maven.org/maven2 for GitHub-hosted
316
330
  # runners with public internet.
317
331
  - name: Install Flyway CLI
318
- if: steps.lakebase-db.outputs.url != '' && hashFiles('pom.xml') != ''
332
+ if: steps.lakebase-db.outputs.url != '' && (hashFiles('pom.xml') != '' || hashFiles('db/migrations/**') != '')
319
333
  env:
320
334
  FLYWAY_DOWNLOAD_BASE_URL: ${{ vars.FLYWAY_DOWNLOAD_BASE_URL }}
321
335
  run: |
@@ -388,6 +402,8 @@ jobs:
388
402
  uv run pytest
389
403
  elif [ "$LANG" = "nodejs" ]; then
390
404
  npm test
405
+ elif [ "$LANG" = "dotnet" ]; then
406
+ dotnet test --no-restore
391
407
  fi
392
408
  else
393
409
  echo "::error::No CI branch URL – cannot run tests without a database. Check the 'Create CI database branch' step for details."
@@ -570,6 +586,10 @@ jobs:
570
586
  desc=$(echo "$base" | sed 's/^[^_]*_//' | sed 's/\.py$//' | tr '_' ' ')
571
587
  [ -n "$rev" ] && echo "| $rev | $desc |" >> "$MD"
572
588
  done
589
+ elif [ "$LANG" = "dotnet" ]; then
590
+ # EF Core or Flyway: the kit's canonical discovery picks the tool + files.
591
+ npx --yes --package="${SCM_UTILS_NPX_PKG}" lakebase-schema-migrate list \
592
+ | jq -r '.[] | if .tool == "flyway" then "| V\(.version) | \(.description) |" else "| \(.version) | \(.description) (EF Core) |" end' >> "$MD"
573
593
  elif [ "$LANG" = "nodejs" ]; then
574
594
  for f in migrations/*.js; do
575
595
  [ -f "$f" ] || continue
@@ -15,6 +15,10 @@ application-local.properties
15
15
  .consort/agent-log.jsonl
16
16
  .consort/run-config.json
17
17
  .consort/drive-live.log
18
+ # The detached drive's live pid file (consort-drive --detach writes it, consort-watch reads it).
19
+ # Per-run scratch; an untracked drive.pid otherwise re-dirties the tree and re-blocks the next
20
+ # experiment cut's clean-tree check.
21
+ .consort/drive.pid
18
22
  .consort/diagnostics/
19
23
  # next.json is the drive's stop-state pointer, rewritten on EVERY stop. Committing it dirties the
20
24
  # tree on the next stop and re-blocks the experiment cut's clean-tree check , it is per-run state,
@@ -46,6 +50,9 @@ application-local.properties
46
50
  .consort/.recorder-state.json
47
51
  .consort/routing-decisions.jsonl
48
52
  .consort/turns/
53
+ # Playwright-MCP's browser scratch (screenshots/traces a UX/review turn drops). Transient
54
+ # tool output, not corpus; ignore it so it never dirties the tree before an experiment cut.
55
+ .playwright-mcp/
49
56
  .sftdd/agent-log.jsonl
50
57
  .sftdd/run-config.json
51
58
  .tdd/agent-log.jsonl
@@ -66,10 +66,63 @@ elif [ -f "$REPO_ROOT/requirements.txt" ] || [ -f "$REPO_ROOT/pyproject.toml" ];
66
66
  source .venv/bin/activate
67
67
  fi
68
68
  alembic upgrade head "$@"
69
+ elif [ -n "$(find "$REPO_ROOT" -maxdepth 1 \( -name '*.sln' -o -name '*.slnx' -o -name '*.csproj' \) -print -quit 2>/dev/null)" ]; then
70
+ # .NET: EF Core or Flyway (db/migrations/).
71
+ if [ -n "${DATABASE_URL:-}" ]; then
72
+ # Explicit target (CI secret, ephemeral-verify DSN): run the tool directly.
73
+ if [ -d "$REPO_ROOT/db/migrations" ]; then
74
+ _np="${DATABASE_URL#postgresql://}"; _cr="${_np%%@*}"; _hp="${_np#*@}"
75
+ # Decode %XX only ('+' is literal in URL userinfo, and backslashes in a
76
+ # credential must not be interpreted as escapes).
77
+ _urldecode() {
78
+ local s="$1" out="" c
79
+ while [ -n "$s" ]; do
80
+ c="${s:0:1}"; s="${s:1}"
81
+ if [ "$c" = "%" ] && [ "${#s}" -ge 2 ]; then
82
+ out+="$(printf "\\x${s:0:2}")"; s="${s:2}"
83
+ else
84
+ out+="$c"
85
+ fi
86
+ done
87
+ printf '%s' "$out"
88
+ }
89
+ FLYWAY_URL="jdbc:postgresql://${_hp}" \
90
+ FLYWAY_USER="$(_urldecode "${_cr%%:*}")" \
91
+ FLYWAY_PASSWORD="$(_urldecode "${_cr#*:}")" \
92
+ flyway "-locations=filesystem:$REPO_ROOT/db/migrations" \
93
+ -baselineOnMigrate=true -baselineVersion=0 migrate "$@"
94
+ else
95
+ # Relative to the repo root (the cwd here): an absolute path through a
96
+ # symlinked directory (e.g. macOS /tmp -> /private/tmp) makes MSBuild see
97
+ # two different project paths and dotnet ef then loses the build output.
98
+ _csproj="$(grep -l "Microsoft.EntityFrameworkCore.Design" \
99
+ $(find . -maxdepth 3 -name '*.csproj' -not -path '*/tests/*' -not -path '*/test/*' \
100
+ -not -path '*/client/*' -not -path '*/bin/*' -not -path '*/obj/*' -not -path '*/node_modules/*' | sort) \
101
+ 2>/dev/null | head -1)"
102
+ [ -n "$_csproj" ] || { echo "No *.csproj references Microsoft.EntityFrameworkCore.Design." >&2; exit 1; }
103
+ # dotnet ef reads project metadata before it builds, so restore first
104
+ # (a fresh clone or a cleaned obj/ has no assets file).
105
+ if [ -f .config/dotnet-tools.json ]; then dotnet tool restore >/dev/null; fi
106
+ dotnet restore "$_csproj" >/dev/null
107
+ # The design-time DbContext factory reads DATABASE_URL, so no password
108
+ # is passed on the command line.
109
+ dotnet ef database update --project "$_csproj" "$@"
110
+ fi
111
+ else
112
+ # Paired branch from .env metadata: the kit's tool-agnostic migrate CLI
113
+ # picks the tool, mints a fresh credential, and refuses protected tiers,
114
+ # exactly as CI does.
115
+ if [ ! -x "$SCRIPT_DIR/lk" ] || [ -z "${LAKEBASE_PROJECT_ID:-}" ] || [ -z "${LAKEBASE_BRANCH_ID:-}" ]; then
116
+ echo "Cannot migrate: need scripts/lk plus LAKEBASE_PROJECT_ID and LAKEBASE_BRANCH_ID (written to .env by the post-checkout hook), or DATABASE_URL." >&2
117
+ exit 1
118
+ fi
119
+ "$SCRIPT_DIR/lk" lakebase-schema-migrate apply \
120
+ --instance "$LAKEBASE_PROJECT_ID" --branch "$LAKEBASE_BRANCH_ID" "$@"
121
+ fi
69
122
  elif [ -f "$REPO_ROOT/package.json" ]; then
70
123
  # Node.js / Knex
71
124
  npx knex migrate:latest "$@"
72
125
  else
73
- echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), or package.json (Node.js)."
126
+ echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), *.sln/*.csproj (.NET), or package.json (Node.js)."
74
127
  exit 1
75
128
  fi
@@ -107,6 +107,11 @@ if [ -d src/main/resources/db/migration ]; then
107
107
  desc="$(echo "$base" | sed -n 's/^V[0-9.]*__\(.*\)\.sql$/\1/p' | tr '_' ' ')"
108
108
  [ -n "$ver" ] && echo "| V$ver | $desc |" >> "$MD"
109
109
  done
110
+ elif [ -n "$(find . -maxdepth 1 \( -name '*.sln' -o -name '*.slnx' -o -name '*.csproj' \) -print -quit 2>/dev/null)" ] \
111
+ && [ -x "$WORK_TREE/scripts/lk" ]; then
112
+ # .NET (EF Core or Flyway): the kit's canonical discovery picks the tool + files.
113
+ "$WORK_TREE/scripts/lk" lakebase-schema-migrate list 2>/dev/null \
114
+ | jq -r '.[] | if .tool == "flyway" then "| V\(.version) | \(.description) |" else "| \(.version) | \(.description) (EF Core) |" end' >> "$MD" || true
110
115
  elif [ -d alembic/versions ]; then
111
116
  # Alembic / Python
112
117
  for f in alembic/versions/*.py; do
@@ -209,6 +209,15 @@ for r in getattr(app, 'routes', []):
209
209
  fi
210
210
 
211
211
  uv run uvicorn app.main:app --reload --host "$HOST" --port "$PORT" "$@"
212
+ elif [ -n "$(find "$REPO_ROOT" -maxdepth 1 \( -name '*.sln' -o -name '*.slnx' -o -name '*.csproj' \) -print -quit 2>/dev/null)" ]; then
213
+ # .NET / ASP.NET Core. The app reads .env itself and mints its own credential.
214
+ PORT="$(resolve_port 8000)"
215
+ export PORT HOST
216
+ # Relative paths (cwd is the repo root): see the symlink note in flyway-migrate.sh.
217
+ _app="$(find src -maxdepth 2 -name '*.csproj' 2>/dev/null | head -1)"
218
+ _app="${_app:-$(find . -maxdepth 1 -name '*.csproj' 2>/dev/null | head -1)}"
219
+ echo "Serving on http://${HOST}:${PORT} (Ctrl-C to stop). Open it in your browser."
220
+ dotnet watch --project "$_app" run -- --urls "http://${HOST}:${PORT}" "$@"
212
221
  elif [ -f "$REPO_ROOT/package.json" ]; then
213
222
  # Node.js – prefer a "dev" script, fall back to "start".
214
223
  PORT="$(resolve_port 8000)"
@@ -220,6 +229,6 @@ elif [ -f "$REPO_ROOT/package.json" ]; then
220
229
  npm start -- "$@"
221
230
  fi
222
231
  else
223
- echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), or package.json (Node.js)."
232
+ echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), *.sln/*.csproj (.NET), or package.json (Node.js)."
224
233
  exit 1
225
234
  fi
@@ -140,13 +140,18 @@ elif [ -f "$REPO_ROOT/requirements.txt" ] || [ -f "$REPO_ROOT/pyproject.toml" ];
140
140
  else
141
141
  uv run --extra dev pytest "$@"
142
142
  fi
143
+ elif [ -n "$(find "$REPO_ROOT" -maxdepth 1 \( -name '*.sln' -o -name '*.slnx' -o -name '*.csproj' \) -print -quit 2>/dev/null)" ]; then
144
+ # .NET / EF Core or Flyway + xUnit
145
+ echo "Running migrations..."
146
+ "$REPO_ROOT/scripts/flyway-migrate.sh"
147
+ dotnet test "$@"
143
148
  elif [ -f "$REPO_ROOT/package.json" ]; then
144
149
  # Node.js / Knex + Jest
145
150
  echo "Running Knex migrations..."
146
151
  npx knex migrate:latest
147
152
  npm test "$@"
148
153
  else
149
- echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), or package.json (Node.js)."
154
+ echo "Could not detect project language. Expected pom.xml (Java), pyproject.toml/requirements.txt (Python), *.sln/*.csproj (.NET), or package.json (Node.js)."
150
155
  exit 1
151
156
  fi
152
157
 
@@ -0,0 +1,8 @@
1
+ # .NET
2
+ bin/
3
+ obj/
4
+ *.user
5
+ *.suo
6
+ .vs/
7
+ TestResults/
8
+ *.trx
@@ -0,0 +1,56 @@
1
+ 
2
+ Microsoft Visual Studio Solution File, Format Version 12.00
3
+ # Visual Studio Version 17
4
+ VisualStudioVersion = 17.0.31903.59
5
+ MinimumVisualStudioVersion = 10.0.40219.1
6
+ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
7
+ EndProject
8
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{DCBDD7E2-BB16-483D-92E7-913AB5681AA2}"
9
+ EndProject
10
+ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
11
+ EndProject
12
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Tests", "tests\App.Tests\App.Tests.csproj", "{3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}"
13
+ EndProject
14
+ Global
15
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
16
+ Debug|Any CPU = Debug|Any CPU
17
+ Debug|x64 = Debug|x64
18
+ Debug|x86 = Debug|x86
19
+ Release|Any CPU = Release|Any CPU
20
+ Release|x64 = Release|x64
21
+ Release|x86 = Release|x86
22
+ EndGlobalSection
23
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
24
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
25
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|Any CPU.Build.0 = Debug|Any CPU
26
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|x64.ActiveCfg = Debug|Any CPU
27
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|x64.Build.0 = Debug|Any CPU
28
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|x86.ActiveCfg = Debug|Any CPU
29
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Debug|x86.Build.0 = Debug|Any CPU
30
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|Any CPU.ActiveCfg = Release|Any CPU
31
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|Any CPU.Build.0 = Release|Any CPU
32
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|x64.ActiveCfg = Release|Any CPU
33
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|x64.Build.0 = Release|Any CPU
34
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|x86.ActiveCfg = Release|Any CPU
35
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2}.Release|x86.Build.0 = Release|Any CPU
36
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
37
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|Any CPU.Build.0 = Debug|Any CPU
38
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|x64.ActiveCfg = Debug|Any CPU
39
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|x64.Build.0 = Debug|Any CPU
40
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|x86.ActiveCfg = Debug|Any CPU
41
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Debug|x86.Build.0 = Debug|Any CPU
42
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|Any CPU.ActiveCfg = Release|Any CPU
43
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|Any CPU.Build.0 = Release|Any CPU
44
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|x64.ActiveCfg = Release|Any CPU
45
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|x64.Build.0 = Release|Any CPU
46
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|x86.ActiveCfg = Release|Any CPU
47
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E}.Release|x86.Build.0 = Release|Any CPU
48
+ EndGlobalSection
49
+ GlobalSection(SolutionProperties) = preSolution
50
+ HideSolutionNode = FALSE
51
+ EndGlobalSection
52
+ GlobalSection(NestedProjects) = preSolution
53
+ {DCBDD7E2-BB16-483D-92E7-913AB5681AA2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
54
+ {3E88FEDA-D8D3-4939-BD9C-CFDD1EEBBC1E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
55
+ EndGlobalSection
56
+ EndGlobal
@@ -0,0 +1,13 @@
1
+ .PHONY: install test migrate run
2
+
3
+ install:
4
+ dotnet restore App.sln
5
+
6
+ test:
7
+ dotnet test App.sln
8
+
9
+ migrate:
10
+ ./scripts/flyway-migrate.sh
11
+
12
+ run:
13
+ dotnet run --project src/App -- --urls "http://127.0.0.1:$${PORT:-8000}"
@@ -0,0 +1,6 @@
1
+ {
2
+ "sdk": {
3
+ "version": "10.0.100",
4
+ "rollForward": "latestFeature"
5
+ }
6
+ }
@@ -0,0 +1,12 @@
1
+ using Microsoft.EntityFrameworkCore;
2
+
3
+ namespace App.Data;
4
+
5
+ /// <summary>
6
+ /// Application DbContext. Add DbSet&lt;TEntity&gt; properties for your tables.
7
+ /// Schema changes go through migrations (see the project README), never
8
+ /// EnsureCreated().
9
+ /// </summary>
10
+ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
11
+ {
12
+ }
@@ -0,0 +1,48 @@
1
+ namespace App.Lakebase;
2
+
3
+ /// <summary>
4
+ /// Minimal .env loader (KEY=VALUE lines, # comments, optional quotes). Never
5
+ /// overwrites variables already set in the process environment.
6
+ /// </summary>
7
+ public static class DotEnv
8
+ {
9
+ public static void LoadFromRepoRoot()
10
+ {
11
+ foreach (var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory })
12
+ {
13
+ for (var dir = new DirectoryInfo(start); dir is not null; dir = dir.Parent)
14
+ {
15
+ var candidate = Path.Combine(dir.FullName, ".env");
16
+ if (File.Exists(candidate))
17
+ {
18
+ Load(candidate);
19
+ return;
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ public static void Load(string path)
26
+ {
27
+ foreach (var raw in File.ReadAllLines(path))
28
+ {
29
+ var line = raw.Trim();
30
+ if (line.Length == 0 || line.StartsWith('#'))
31
+ continue;
32
+ if (line.StartsWith("export ", StringComparison.Ordinal))
33
+ line = line[7..].TrimStart();
34
+
35
+ var eq = line.IndexOf('=');
36
+ if (eq <= 0)
37
+ continue;
38
+
39
+ var key = line[..eq].Trim();
40
+ var value = line[(eq + 1)..].Trim();
41
+ if (value.Length >= 2 && (value[0] == '"' || value[0] == '\'') && value[^1] == value[0])
42
+ value = value[1..^1];
43
+
44
+ if (Environment.GetEnvironmentVariable(key) is null)
45
+ Environment.SetEnvironmentVariable(key, value);
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,225 @@
1
+ using System.Diagnostics;
2
+ using System.Text.Json;
3
+ using Microsoft.EntityFrameworkCore;
4
+ using Microsoft.Extensions.DependencyInjection.Extensions;
5
+ using Npgsql;
6
+
7
+ namespace App.Lakebase;
8
+
9
+ /// <summary>
10
+ /// Builds the app's Postgres connection. Credentials are minted at RUNTIME, never
11
+ /// stored in .env: the data source is built from connection METADATA
12
+ /// (LAKEBASE_HOST, LAKEBASE_PROJECT_ID, LAKEBASE_BRANCH_ID, DB_USERNAME) and a
13
+ /// periodic password provider injects a short-lived Lakebase credential minted by
14
+ /// the databricks CLI (the same credential source as the Python and Node templates).
15
+ ///
16
+ /// An explicit DATABASE_URL (CI secret, Docker, the kit's ephemeral-verify DSN)
17
+ /// OVERRIDES this and is used verbatim. postgresql:// URLs and Npgsql
18
+ /// "Host=...;" strings are both accepted.
19
+ /// </summary>
20
+ public static class LakebaseConnection
21
+ {
22
+ // Lakebase credentials live ~1h; re-mint after 40 min so a new physical
23
+ // connection never presents an expired token.
24
+ private static readonly TimeSpan MintTtl = TimeSpan.FromMinutes(40);
25
+ private static readonly SemaphoreSlim MintLock = new(1, 1);
26
+ private static string? _cachedToken;
27
+ private static DateTime _mintedAt = DateTime.MinValue;
28
+
29
+ /// <summary>
30
+ /// Registers one process-wide NpgsqlDataSource and a DbContext that uses it.
31
+ /// Never build a data source per DbContext: that creates a new pool and mints a
32
+ /// new credential on every request.
33
+ /// </summary>
34
+ public static IServiceCollection AddLakebaseDbContext<TContext>(this IServiceCollection services)
35
+ where TContext : DbContext
36
+ {
37
+ services.TryAddSingleton(_ => CreateDataSource());
38
+ services.AddDbContext<TContext>((sp, options) =>
39
+ options.UseNpgsql(sp.GetRequiredService<NpgsqlDataSource>()));
40
+ return services;
41
+ }
42
+
43
+ /// <summary>Data source for the app (and the EF Core design-time factory).</summary>
44
+ public static NpgsqlDataSource CreateDataSource()
45
+ {
46
+ var explicitUrl = Environment.GetEnvironmentVariable("DATABASE_URL");
47
+ if (!string.IsNullOrWhiteSpace(explicitUrl))
48
+ return NpgsqlDataSource.Create(Normalize(new NpgsqlConnectionStringBuilder(ToNpgsql(explicitUrl))).ConnectionString);
49
+
50
+ var host = Env("LAKEBASE_HOST") ?? Env("DB_HOST");
51
+ var endpoint = EndpointPathFromEnv();
52
+ var builder = new NpgsqlConnectionStringBuilder
53
+ {
54
+ Host = host ?? "localhost",
55
+ Port = 5432,
56
+ Database = Env("DB_NAME") ?? "databricks_postgres",
57
+ };
58
+
59
+ if (host is null || endpoint is null)
60
+ return NpgsqlDataSource.Create(Normalize(builder).ConnectionString); // pure-local fallback
61
+
62
+ // Resolve the user without failing here: design-time commands such as
63
+ // `dotnet ef migrations add` build a data source but never connect, so
64
+ // an offline or expired CLI session must only fail when a connection
65
+ // is actually opened.
66
+ Exception? userError = null;
67
+ var user = Env("DB_USERNAME");
68
+ if (user is null)
69
+ {
70
+ try { user = CurrentUser(); }
71
+ catch (Exception ex) { userError = ex; }
72
+ }
73
+
74
+ builder.Username = user ?? "unresolved-lakebase-user";
75
+ var dataSourceBuilder = new NpgsqlDataSourceBuilder(Normalize(builder).ConnectionString);
76
+ dataSourceBuilder.UsePeriodicPasswordProvider(
77
+ async (_, ct) => userError is null
78
+ ? await MintTokenAsync(endpoint, ct)
79
+ : throw new InvalidOperationException(
80
+ "Could not resolve the Lakebase user. Set DB_USERNAME in .env or run 'databricks auth login'.", userError),
81
+ successRefreshInterval: TimeSpan.FromMinutes(5),
82
+ failureRefreshInterval: TimeSpan.FromSeconds(10));
83
+ return dataSourceBuilder.Build();
84
+ }
85
+
86
+ /// <summary>projects/&lt;id&gt;/branches/&lt;branch&gt;/endpoints/&lt;endpoint&gt;, or null without metadata.</summary>
87
+ public static string? EndpointPathFromEnv()
88
+ {
89
+ var project = Env("LAKEBASE_PROJECT_ID");
90
+ var branch = Env("LAKEBASE_BRANCH_ID");
91
+ if (project is null || branch is null)
92
+ return null;
93
+ return $"projects/{project}/branches/{branch}/endpoints/{Env("LAKEBASE_ENDPOINT") ?? "primary"}";
94
+ }
95
+
96
+ /// <summary>Convert a postgresql:// URL to an Npgsql connection string; pass Npgsql strings through.</summary>
97
+ public static string ToNpgsql(string databaseUrl)
98
+ {
99
+ var raw = databaseUrl.Trim();
100
+ if (!raw.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase)
101
+ && !raw.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase))
102
+ return raw;
103
+
104
+ var uri = new Uri(raw);
105
+ var builder = new NpgsqlConnectionStringBuilder
106
+ {
107
+ Host = uri.Host,
108
+ Port = uri.Port > 0 ? uri.Port : 5432,
109
+ Database = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')),
110
+ };
111
+ var userInfo = uri.UserInfo.Split(':', 2);
112
+ if (userInfo[0].Length > 0)
113
+ builder.Username = Uri.UnescapeDataString(userInfo[0]);
114
+ if (userInfo.Length > 1)
115
+ builder.Password = Uri.UnescapeDataString(userInfo[1]);
116
+
117
+ foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
118
+ {
119
+ var kv = pair.Split('=', 2);
120
+ var value = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]) : "";
121
+ switch (kv[0].ToLowerInvariant())
122
+ {
123
+ case "sslmode":
124
+ builder.SslMode = value.ToLowerInvariant() switch
125
+ {
126
+ "disable" => SslMode.Disable,
127
+ "allow" => SslMode.Allow,
128
+ "prefer" => SslMode.Prefer,
129
+ "verify-ca" => SslMode.VerifyCA,
130
+ "verify-full" => SslMode.VerifyFull,
131
+ _ => SslMode.Require,
132
+ };
133
+ break;
134
+ case "application_name":
135
+ builder.ApplicationName = value;
136
+ break;
137
+ }
138
+ }
139
+
140
+ return builder.ConnectionString;
141
+ }
142
+
143
+ // TLS on and a non-empty application_name, so every connection is
144
+ // identifiable in pg_stat_activity.
145
+ private static NpgsqlConnectionStringBuilder Normalize(NpgsqlConnectionStringBuilder builder)
146
+ {
147
+ var weakTls = builder.SslMode is SslMode.Disable or SslMode.Allow or SslMode.Prefer;
148
+ if (weakTls && builder.Host != "localhost")
149
+ builder.SslMode = SslMode.Require;
150
+ if (string.IsNullOrEmpty(builder.ApplicationName))
151
+ {
152
+ var consort = Env("CONSORT_VERSION");
153
+ builder.ApplicationName = Env("PGAPPNAME")
154
+ ?? (consort is not null ? $"consort/{consort}" : "scm-utils/{{LAKEBASE_SCM_UTILS_VERSION}}");
155
+ }
156
+
157
+ return builder;
158
+ }
159
+
160
+ private static async Task<string> MintTokenAsync(string endpoint, CancellationToken ct)
161
+ {
162
+ await MintLock.WaitAsync(ct);
163
+ try
164
+ {
165
+ if (_cachedToken is not null && DateTime.UtcNow - _mintedAt < MintTtl)
166
+ return _cachedToken;
167
+
168
+ var json = await RunDatabricksAsync(["postgres", "generate-database-credential", endpoint, "-o", "json"], ct);
169
+ using var doc = JsonDocument.Parse(json);
170
+ _cachedToken = doc.RootElement.GetProperty("token").GetString()
171
+ ?? throw new InvalidOperationException($"generate-database-credential returned no token for {endpoint}");
172
+ _mintedAt = DateTime.UtcNow;
173
+ return _cachedToken;
174
+ }
175
+ finally
176
+ {
177
+ MintLock.Release();
178
+ }
179
+ }
180
+
181
+ private static string CurrentUser()
182
+ {
183
+ var json = RunDatabricksAsync(["current-user", "me", "-o", "json"], CancellationToken.None).GetAwaiter().GetResult();
184
+ using var doc = JsonDocument.Parse(json);
185
+ return doc.RootElement.GetProperty("userName").GetString()
186
+ ?? throw new InvalidOperationException("databricks current-user me returned no userName; set DB_USERNAME in .env");
187
+ }
188
+
189
+ private static async Task<string> RunDatabricksAsync(IEnumerable<string> args, CancellationToken ct)
190
+ {
191
+ var psi = new ProcessStartInfo("databricks")
192
+ {
193
+ RedirectStandardOutput = true,
194
+ RedirectStandardError = true,
195
+ UseShellExecute = false,
196
+ };
197
+ foreach (var arg in args)
198
+ psi.ArgumentList.Add(arg);
199
+ if (Env("DATABRICKS_CONFIG_PROFILE") is { } profile)
200
+ {
201
+ psi.ArgumentList.Add("--profile");
202
+ psi.ArgumentList.Add(profile);
203
+ }
204
+
205
+ using var process = Process.Start(psi)
206
+ ?? throw new InvalidOperationException("Could not start the databricks CLI. Is it installed and on PATH?");
207
+ var stdout = process.StandardOutput.ReadToEndAsync(ct);
208
+ var stderr = process.StandardError.ReadToEndAsync(ct);
209
+ await process.WaitForExitAsync(ct);
210
+ if (process.ExitCode != 0)
211
+ {
212
+ var err = (await stderr).Trim();
213
+ var hint = err.Contains("auth login", StringComparison.OrdinalIgnoreCase)
214
+ || err.Contains("refresh token", StringComparison.OrdinalIgnoreCase)
215
+ ? " Run 'databricks auth login' to re-authenticate."
216
+ : "";
217
+ throw new InvalidOperationException($"databricks {string.Join(' ', psi.ArgumentList)} failed: {err}{hint}");
218
+ }
219
+
220
+ return await stdout;
221
+ }
222
+
223
+ private static string? Env(string name)
224
+ => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null;
225
+ }
@@ -0,0 +1,34 @@
1
+ using App.Data;
2
+ using App.Lakebase;
3
+
4
+ // Load .env from the repo root so the Lakebase connection metadata written by
5
+ // the post-checkout hook is available however the app is started. Variables
6
+ // already set in the environment (CI secrets, Docker --env) always win.
7
+ DotEnv.LoadFromRepoRoot();
8
+
9
+ var builder = WebApplication.CreateBuilder(args);
10
+
11
+ // One process-wide data source: a single connection pool whose password is a
12
+ // short-lived Lakebase credential refreshed in the background.
13
+ builder.Services.AddLakebaseDbContext<AppDbContext>();
14
+
15
+ // OpenAPI description of the endpoints, browsable at /docs (like the Node template).
16
+ builder.Services.AddOpenApi();
17
+
18
+ var app = builder.Build();
19
+
20
+ app.MapOpenApi("/openapi.json");
21
+ app.UseSwaggerUI(options =>
22
+ {
23
+ options.RoutePrefix = "docs";
24
+ options.SwaggerEndpoint("/openapi.json", "API");
25
+ });
26
+
27
+ app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
28
+
29
+ // Register API endpoints above this line.
30
+
31
+ app.Run();
32
+
33
+ // Exposed for WebApplicationFactory<Program> in tests.
34
+ public partial class Program;
@@ -0,0 +1,9 @@
1
+ {
2
+ "Logging": {
3
+ "LogLevel": {
4
+ "Default": "Information",
5
+ "Microsoft.AspNetCore": "Warning"
6
+ }
7
+ },
8
+ "AllowedHosts": "*"
9
+ }
@@ -0,0 +1,25 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+
3
+ <PropertyGroup>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <ImplicitUsings>enable</ImplicitUsings>
6
+ <Nullable>enable</Nullable>
7
+ <IsPackable>false</IsPackable>
8
+ </PropertyGroup>
9
+
10
+ <ItemGroup>
11
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
12
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
13
+ <PackageReference Include="xunit" Version="2.9.3" />
14
+ <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
15
+ </ItemGroup>
16
+
17
+ <ItemGroup>
18
+ <Using Include="Xunit" />
19
+ </ItemGroup>
20
+
21
+ <ItemGroup>
22
+ <ProjectReference Include="..\..\src\App\App.csproj" />
23
+ </ItemGroup>
24
+
25
+ </Project>