@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.
- package/dist/scripts/index.cjs +2636 -2124
- package/dist/scripts/index.d.cts +1 -1
- package/dist/scripts/index.d.ts +1 -1
- package/dist/scripts/index.js +680 -178
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2460 -2030
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +508 -81
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +2429 -2007
- package/dist/scripts/lakebase/collapse-heads.cli.js +477 -58
- package/dist/scripts/lakebase/detect-language.cli.cjs +2441 -2016
- package/dist/scripts/lakebase/detect-language.cli.js +489 -67
- package/dist/scripts/lakebase/doctor.cli.cjs +2484 -2040
- package/dist/scripts/lakebase/doctor.cli.js +532 -91
- package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
- package/dist/scripts/lakebase/get-connection.cli.js +2 -2
- package/dist/scripts/lakebase/index.cjs +2633 -2121
- package/dist/scripts/lakebase/index.d.cts +59 -10
- package/dist/scripts/lakebase/index.d.ts +59 -10
- package/dist/scripts/lakebase/index.js +674 -172
- package/dist/scripts/lakebase/infra-runner.cli.cjs +2435 -2013
- package/dist/scripts/lakebase/infra-runner.cli.js +483 -64
- package/dist/scripts/lakebase/new-migration.cli.cjs +2429 -2007
- package/dist/scripts/lakebase/new-migration.cli.js +477 -58
- package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
- package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +2461 -2029
- package/dist/scripts/lakebase/schema-migrate.cli.js +509 -80
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2460 -2030
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +508 -81
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +2486 -2056
- package/dist/scripts/lakebase/scm-doctor.cli.js +537 -107
- package/dist/scripts/lakebase/scm-merge.cli.cjs +2443 -2021
- package/dist/scripts/lakebase/scm-merge.cli.js +494 -72
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2464 -2034
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +511 -84
- package/dist/scripts/util/index.cjs +2 -2
- package/dist/scripts/util/index.js +2 -2
- package/package.json +1 -1
- package/scripts/lakebase/adapters/efcore-adapter.ts +158 -0
- package/scripts/lakebase/adapters/flyway-adapter.ts +8 -5
- package/scripts/lakebase/collapse-heads.cli.ts +1 -1
- package/scripts/lakebase/create-project.ts +14 -12
- package/scripts/lakebase/detect-language.cli.ts +2 -2
- package/scripts/lakebase/doctor.ts +29 -0
- package/scripts/lakebase/dotnet-project.ts +112 -0
- package/scripts/lakebase/index.ts +1 -0
- package/scripts/lakebase/migration-layout.ts +43 -2
- package/scripts/lakebase/new-migration.cli.ts +1 -1
- package/scripts/lakebase/scaffold-language.ts +23 -2
- package/scripts/lakebase/scaffold.ts +4 -1
- package/scripts/lakebase/schema-migrate-runners/efcore.ts +399 -0
- package/scripts/lakebase/schema-migrate-runners/flyway.ts +6 -4
- package/scripts/lakebase/schema-migrate.cli.ts +3 -1
- package/scripts/lakebase/schema-migrate.ts +24 -10
- package/templates/project/common/.github/workflows/merge.yml +13 -1
- package/templates/project/common/.github/workflows/pr.yml +25 -5
- package/templates/project/common/.gitignore.base +7 -0
- package/templates/project/common/scripts/flyway-migrate.sh +54 -1
- package/templates/project/common/scripts/prepare-schema-diff.sh +5 -0
- package/templates/project/common/scripts/run-dev.sh +10 -1
- package/templates/project/common/scripts/run-tests.sh +6 -1
- package/templates/project/dotnet/.gitignore.extra +8 -0
- package/templates/project/dotnet/App.sln +56 -0
- package/templates/project/dotnet/Makefile +13 -0
- package/templates/project/dotnet/global.json +6 -0
- package/templates/project/dotnet/src/App/Data/AppDbContext.cs +12 -0
- package/templates/project/dotnet/src/App/Lakebase/DotEnv.cs +48 -0
- package/templates/project/dotnet/src/App/Lakebase/LakebaseConnection.cs +225 -0
- package/templates/project/dotnet/src/App/Program.cs +34 -0
- package/templates/project/dotnet/src/App/appsettings.json +9 -0
- package/templates/project/dotnet/tests/App.Tests/App.Tests.csproj +25 -0
- package/templates/project/dotnet/tests/App.Tests/HealthTests.cs +18 -0
- package/templates/project/dotnet/tests/App.Tests/OpenApiTests.cs +23 -0
- package/templates/project/dotnet-efcore/.config/dotnet-tools.json +13 -0
- package/templates/project/dotnet-efcore/src/App/App.csproj +22 -0
- package/templates/project/dotnet-efcore/src/App/Data/DesignTimeDbContextFactory.cs +24 -0
- package/templates/project/dotnet-efcore/src/App/Migrations/README.md +23 -0
- package/templates/project/dotnet-flyway/db/migrations/README.md +23 -0
- 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
|
|
305
|
-
# migrate runner spawns the
|
|
306
|
-
# projects don't need it. Runtime-gated
|
|
307
|
-
#
|
|
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,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,12 @@
|
|
|
1
|
+
using Microsoft.EntityFrameworkCore;
|
|
2
|
+
|
|
3
|
+
namespace App.Data;
|
|
4
|
+
|
|
5
|
+
/// <summary>
|
|
6
|
+
/// Application DbContext. Add DbSet<TEntity> 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/<id>/branches/<branch>/endpoints/<endpoint>, 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,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>
|