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

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 (38) hide show
  1. package/dist/scripts/index.cjs +26 -9
  2. package/dist/scripts/index.js +27 -10
  3. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +26 -9
  4. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +27 -10
  5. package/dist/scripts/lakebase/collapse-heads.cli.cjs +26 -9
  6. package/dist/scripts/lakebase/collapse-heads.cli.js +27 -10
  7. package/dist/scripts/lakebase/detect-language.cli.cjs +26 -9
  8. package/dist/scripts/lakebase/detect-language.cli.js +27 -10
  9. package/dist/scripts/lakebase/doctor.cli.cjs +26 -9
  10. package/dist/scripts/lakebase/doctor.cli.js +27 -10
  11. package/dist/scripts/lakebase/get-connection.cli.cjs +2 -2
  12. package/dist/scripts/lakebase/get-connection.cli.js +2 -2
  13. package/dist/scripts/lakebase/index.cjs +26 -9
  14. package/dist/scripts/lakebase/index.js +27 -10
  15. package/dist/scripts/lakebase/infra-runner.cli.cjs +26 -9
  16. package/dist/scripts/lakebase/infra-runner.cli.js +27 -10
  17. package/dist/scripts/lakebase/new-migration.cli.cjs +26 -9
  18. package/dist/scripts/lakebase/new-migration.cli.js +27 -10
  19. package/dist/scripts/lakebase/schema-diff.cli.cjs +2 -2
  20. package/dist/scripts/lakebase/schema-diff.cli.js +2 -2
  21. package/dist/scripts/lakebase/schema-migrate.cli.cjs +26 -9
  22. package/dist/scripts/lakebase/schema-migrate.cli.js +27 -10
  23. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +26 -9
  24. package/dist/scripts/lakebase/scm-claim-feature.cli.js +27 -10
  25. package/dist/scripts/lakebase/scm-doctor.cli.cjs +26 -9
  26. package/dist/scripts/lakebase/scm-doctor.cli.js +27 -10
  27. package/dist/scripts/lakebase/scm-merge.cli.cjs +26 -9
  28. package/dist/scripts/lakebase/scm-merge.cli.js +27 -10
  29. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +26 -9
  30. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +28 -11
  31. package/dist/scripts/util/index.cjs +2 -2
  32. package/dist/scripts/util/index.js +2 -2
  33. package/package.json +1 -1
  34. package/scripts/lakebase/schema-migrate-runners/alembic.ts +58 -14
  35. package/templates/project/client/playwright.config.ts +45 -10
  36. package/templates/project/python/app/database.py +6 -6
  37. package/templates/project/python/app/repositories/__init__.py +8 -0
  38. package/templates/project/python/app/repositories/session.py +28 -0
@@ -88240,8 +88240,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
88240
88240
  var cached;
88241
88241
  function substrateSelfVersion() {
88242
88242
  if (cached !== void 0) return cached;
88243
- if ("0.3.1".length > 0) {
88244
- cached = "0.3.1";
88243
+ if ("0.3.3".length > 0) {
88244
+ cached = "0.3.3";
88245
88245
  return cached;
88246
88246
  }
88247
88247
  cached = "unknown";
@@ -89118,26 +89118,43 @@ init_cjs_shims();
89118
89118
  var import_node_child_process6 = require("child_process");
89119
89119
  var fs9 = __toESM(require("fs"), 1);
89120
89120
  var path7 = __toESM(require("path"), 1);
89121
- function resolveAlembicBin(projectDir) {
89122
- const candidates = [
89121
+ var uvOnPath;
89122
+ function hasUv() {
89123
+ if (uvOnPath !== void 0) return uvOnPath;
89124
+ try {
89125
+ (0, import_node_child_process6.execFileSync)("uv", ["--version"], { stdio: "ignore" });
89126
+ uvOnPath = true;
89127
+ } catch {
89128
+ uvOnPath = false;
89129
+ }
89130
+ return uvOnPath;
89131
+ }
89132
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
89133
+ const venvCandidates = [
89123
89134
  path7.join(projectDir, ".venv", "bin", "alembic"),
89124
89135
  path7.join(projectDir, "venv", "bin", "alembic")
89125
89136
  ];
89126
- for (const candidate of candidates) {
89137
+ for (const candidate of venvCandidates) {
89127
89138
  try {
89128
- if (fs9.existsSync(candidate)) return candidate;
89139
+ if (fs9.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
89129
89140
  } catch {
89130
89141
  }
89131
89142
  }
89132
- return "alembic";
89143
+ try {
89144
+ if (uvAvailable && fs9.existsSync(path7.join(projectDir, "pyproject.toml"))) {
89145
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
89146
+ }
89147
+ } catch {
89148
+ }
89149
+ return { bin: "alembic", prefixArgs: [] };
89133
89150
  }
89134
89151
  function spawnAlembic(projectDir, args, dsn) {
89135
89152
  return new Promise((resolve3, reject) => {
89136
- const bin = resolveAlembicBin(projectDir);
89153
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
89137
89154
  const env = { ...process.env };
89138
89155
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path7.delimiter);
89139
89156
  if (dsn) env.DATABASE_URL = dsn;
89140
- const child = (0, import_node_child_process6.spawn)(bin, args, {
89157
+ const child = (0, import_node_child_process6.spawn)(bin, [...prefixArgs, ...args], {
89141
89158
  cwd: projectDir,
89142
89159
  env,
89143
89160
  stdio: ["ignore", "pipe", "pipe"]
@@ -9393,8 +9393,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
9393
9393
  var cached;
9394
9394
  function substrateSelfVersion() {
9395
9395
  if (cached !== void 0) return cached;
9396
- if ("0.3.1".length > 0) {
9397
- cached = "0.3.1";
9396
+ if ("0.3.3".length > 0) {
9397
+ cached = "0.3.3";
9398
9398
  return cached;
9399
9399
  }
9400
9400
  cached = "unknown";
@@ -10268,29 +10268,46 @@ import * as path9 from "path";
10268
10268
 
10269
10269
  // scripts/lakebase/schema-migrate-runners/alembic.ts
10270
10270
  init_esm_shims();
10271
- import { spawn } from "child_process";
10271
+ import { spawn, execFileSync as execFileSync6 } from "child_process";
10272
10272
  import * as fs8 from "fs";
10273
10273
  import * as path8 from "path";
10274
- function resolveAlembicBin(projectDir) {
10275
- const candidates = [
10274
+ var uvOnPath;
10275
+ function hasUv() {
10276
+ if (uvOnPath !== void 0) return uvOnPath;
10277
+ try {
10278
+ execFileSync6("uv", ["--version"], { stdio: "ignore" });
10279
+ uvOnPath = true;
10280
+ } catch {
10281
+ uvOnPath = false;
10282
+ }
10283
+ return uvOnPath;
10284
+ }
10285
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
10286
+ const venvCandidates = [
10276
10287
  path8.join(projectDir, ".venv", "bin", "alembic"),
10277
10288
  path8.join(projectDir, "venv", "bin", "alembic")
10278
10289
  ];
10279
- for (const candidate of candidates) {
10290
+ for (const candidate of venvCandidates) {
10280
10291
  try {
10281
- if (fs8.existsSync(candidate)) return candidate;
10292
+ if (fs8.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
10282
10293
  } catch {
10283
10294
  }
10284
10295
  }
10285
- return "alembic";
10296
+ try {
10297
+ if (uvAvailable && fs8.existsSync(path8.join(projectDir, "pyproject.toml"))) {
10298
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
10299
+ }
10300
+ } catch {
10301
+ }
10302
+ return { bin: "alembic", prefixArgs: [] };
10286
10303
  }
10287
10304
  function spawnAlembic(projectDir, args, dsn) {
10288
10305
  return new Promise((resolve3, reject) => {
10289
- const bin = resolveAlembicBin(projectDir);
10306
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
10290
10307
  const env = { ...process.env };
10291
10308
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path8.delimiter);
10292
10309
  if (dsn) env.DATABASE_URL = dsn;
10293
- const child = spawn(bin, args, {
10310
+ const child = spawn(bin, [...prefixArgs, ...args], {
10294
10311
  cwd: projectDir,
10295
10312
  env,
10296
10313
  stdio: ["ignore", "pipe", "pipe"]
@@ -88825,8 +88825,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
88825
88825
  var cached;
88826
88826
  function substrateSelfVersion() {
88827
88827
  if (cached !== void 0) return cached;
88828
- if ("0.3.1".length > 0) {
88829
- cached = "0.3.1";
88828
+ if ("0.3.3".length > 0) {
88829
+ cached = "0.3.3";
88830
88830
  return cached;
88831
88831
  }
88832
88832
  cached = "unknown";
@@ -89010,26 +89010,43 @@ init_cjs_shims();
89010
89010
  var import_node_child_process5 = require("child_process");
89011
89011
  var fs8 = __toESM(require("fs"), 1);
89012
89012
  var path7 = __toESM(require("path"), 1);
89013
- function resolveAlembicBin(projectDir) {
89014
- const candidates = [
89013
+ var uvOnPath;
89014
+ function hasUv() {
89015
+ if (uvOnPath !== void 0) return uvOnPath;
89016
+ try {
89017
+ (0, import_node_child_process5.execFileSync)("uv", ["--version"], { stdio: "ignore" });
89018
+ uvOnPath = true;
89019
+ } catch {
89020
+ uvOnPath = false;
89021
+ }
89022
+ return uvOnPath;
89023
+ }
89024
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
89025
+ const venvCandidates = [
89015
89026
  path7.join(projectDir, ".venv", "bin", "alembic"),
89016
89027
  path7.join(projectDir, "venv", "bin", "alembic")
89017
89028
  ];
89018
- for (const candidate of candidates) {
89029
+ for (const candidate of venvCandidates) {
89019
89030
  try {
89020
- if (fs8.existsSync(candidate)) return candidate;
89031
+ if (fs8.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
89021
89032
  } catch {
89022
89033
  }
89023
89034
  }
89024
- return "alembic";
89035
+ try {
89036
+ if (uvAvailable && fs8.existsSync(path7.join(projectDir, "pyproject.toml"))) {
89037
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
89038
+ }
89039
+ } catch {
89040
+ }
89041
+ return { bin: "alembic", prefixArgs: [] };
89025
89042
  }
89026
89043
  function spawnAlembic(projectDir, args, dsn) {
89027
89044
  return new Promise((resolve2, reject) => {
89028
- const bin = resolveAlembicBin(projectDir);
89045
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
89029
89046
  const env = { ...process.env };
89030
89047
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path7.delimiter);
89031
89048
  if (dsn) env.DATABASE_URL = dsn;
89032
- const child = (0, import_node_child_process5.spawn)(bin, args, {
89049
+ const child = (0, import_node_child_process5.spawn)(bin, [...prefixArgs, ...args], {
89033
89050
  cwd: projectDir,
89034
89051
  env,
89035
89052
  stdio: ["ignore", "pipe", "pipe"]
@@ -9978,8 +9978,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
9978
9978
  var cached;
9979
9979
  function substrateSelfVersion() {
9980
9980
  if (cached !== void 0) return cached;
9981
- if ("0.3.1".length > 0) {
9982
- cached = "0.3.1";
9981
+ if ("0.3.3".length > 0) {
9982
+ cached = "0.3.3";
9983
9983
  return cached;
9984
9984
  }
9985
9985
  cached = "unknown";
@@ -10160,29 +10160,46 @@ import * as path9 from "path";
10160
10160
 
10161
10161
  // scripts/lakebase/schema-migrate-runners/alembic.ts
10162
10162
  init_esm_shims();
10163
- import { spawn } from "child_process";
10163
+ import { spawn, execFileSync as execFileSync5 } from "child_process";
10164
10164
  import * as fs7 from "fs";
10165
10165
  import * as path8 from "path";
10166
- function resolveAlembicBin(projectDir) {
10167
- const candidates = [
10166
+ var uvOnPath;
10167
+ function hasUv() {
10168
+ if (uvOnPath !== void 0) return uvOnPath;
10169
+ try {
10170
+ execFileSync5("uv", ["--version"], { stdio: "ignore" });
10171
+ uvOnPath = true;
10172
+ } catch {
10173
+ uvOnPath = false;
10174
+ }
10175
+ return uvOnPath;
10176
+ }
10177
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
10178
+ const venvCandidates = [
10168
10179
  path8.join(projectDir, ".venv", "bin", "alembic"),
10169
10180
  path8.join(projectDir, "venv", "bin", "alembic")
10170
10181
  ];
10171
- for (const candidate of candidates) {
10182
+ for (const candidate of venvCandidates) {
10172
10183
  try {
10173
- if (fs7.existsSync(candidate)) return candidate;
10184
+ if (fs7.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
10174
10185
  } catch {
10175
10186
  }
10176
10187
  }
10177
- return "alembic";
10188
+ try {
10189
+ if (uvAvailable && fs7.existsSync(path8.join(projectDir, "pyproject.toml"))) {
10190
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
10191
+ }
10192
+ } catch {
10193
+ }
10194
+ return { bin: "alembic", prefixArgs: [] };
10178
10195
  }
10179
10196
  function spawnAlembic(projectDir, args, dsn) {
10180
10197
  return new Promise((resolve2, reject) => {
10181
- const bin = resolveAlembicBin(projectDir);
10198
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
10182
10199
  const env = { ...process.env };
10183
10200
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path8.delimiter);
10184
10201
  if (dsn) env.DATABASE_URL = dsn;
10185
- const child = spawn(bin, args, {
10202
+ const child = spawn(bin, [...prefixArgs, ...args], {
10186
10203
  cwd: projectDir,
10187
10204
  env,
10188
10205
  stdio: ["ignore", "pipe", "pipe"]
@@ -79288,8 +79288,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
79288
79288
  var cached;
79289
79289
  function substrateSelfVersion() {
79290
79290
  if (cached !== void 0) return cached;
79291
- if ("0.3.1".length > 0) {
79292
- cached = "0.3.1";
79291
+ if ("0.3.3".length > 0) {
79292
+ cached = "0.3.3";
79293
79293
  return cached;
79294
79294
  }
79295
79295
  cached = "unknown";
@@ -79547,26 +79547,43 @@ init_cjs_shims();
79547
79547
  var import_node_child_process3 = require("child_process");
79548
79548
  var fs7 = __toESM(require("fs"), 1);
79549
79549
  var path5 = __toESM(require("path"), 1);
79550
- function resolveAlembicBin(projectDir) {
79551
- const candidates = [
79550
+ var uvOnPath;
79551
+ function hasUv() {
79552
+ if (uvOnPath !== void 0) return uvOnPath;
79553
+ try {
79554
+ (0, import_node_child_process3.execFileSync)("uv", ["--version"], { stdio: "ignore" });
79555
+ uvOnPath = true;
79556
+ } catch {
79557
+ uvOnPath = false;
79558
+ }
79559
+ return uvOnPath;
79560
+ }
79561
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
79562
+ const venvCandidates = [
79552
79563
  path5.join(projectDir, ".venv", "bin", "alembic"),
79553
79564
  path5.join(projectDir, "venv", "bin", "alembic")
79554
79565
  ];
79555
- for (const candidate of candidates) {
79566
+ for (const candidate of venvCandidates) {
79556
79567
  try {
79557
- if (fs7.existsSync(candidate)) return candidate;
79568
+ if (fs7.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
79558
79569
  } catch {
79559
79570
  }
79560
79571
  }
79561
- return "alembic";
79572
+ try {
79573
+ if (uvAvailable && fs7.existsSync(path5.join(projectDir, "pyproject.toml"))) {
79574
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
79575
+ }
79576
+ } catch {
79577
+ }
79578
+ return { bin: "alembic", prefixArgs: [] };
79562
79579
  }
79563
79580
  function spawnAlembic(projectDir, args, dsn) {
79564
79581
  return new Promise((resolve2, reject) => {
79565
- const bin = resolveAlembicBin(projectDir);
79582
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
79566
79583
  const env = { ...process.env };
79567
79584
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path5.delimiter);
79568
79585
  if (dsn) env.DATABASE_URL = dsn;
79569
- const child = (0, import_node_child_process3.spawn)(bin, args, {
79586
+ const child = (0, import_node_child_process3.spawn)(bin, [...prefixArgs, ...args], {
79570
79587
  cwd: projectDir,
79571
79588
  env,
79572
79589
  stdio: ["ignore", "pipe", "pipe"]
@@ -389,8 +389,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
389
389
  var cached;
390
390
  function substrateSelfVersion() {
391
391
  if (cached !== void 0) return cached;
392
- if ("0.3.1".length > 0) {
393
- cached = "0.3.1";
392
+ if ("0.3.3".length > 0) {
393
+ cached = "0.3.3";
394
394
  return cached;
395
395
  }
396
396
  cached = "unknown";
@@ -640,29 +640,46 @@ import * as fs7 from "fs";
640
640
  import * as path6 from "path";
641
641
 
642
642
  // scripts/lakebase/schema-migrate-runners/alembic.ts
643
- import { spawn } from "child_process";
643
+ import { spawn, execFileSync as execFileSync3 } from "child_process";
644
644
  import * as fs6 from "fs";
645
645
  import * as path5 from "path";
646
- function resolveAlembicBin(projectDir) {
647
- const candidates = [
646
+ var uvOnPath;
647
+ function hasUv() {
648
+ if (uvOnPath !== void 0) return uvOnPath;
649
+ try {
650
+ execFileSync3("uv", ["--version"], { stdio: "ignore" });
651
+ uvOnPath = true;
652
+ } catch {
653
+ uvOnPath = false;
654
+ }
655
+ return uvOnPath;
656
+ }
657
+ function resolveAlembicInvocation(projectDir, uvAvailable = hasUv()) {
658
+ const venvCandidates = [
648
659
  path5.join(projectDir, ".venv", "bin", "alembic"),
649
660
  path5.join(projectDir, "venv", "bin", "alembic")
650
661
  ];
651
- for (const candidate of candidates) {
662
+ for (const candidate of venvCandidates) {
652
663
  try {
653
- if (fs6.existsSync(candidate)) return candidate;
664
+ if (fs6.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
654
665
  } catch {
655
666
  }
656
667
  }
657
- return "alembic";
668
+ try {
669
+ if (uvAvailable && fs6.existsSync(path5.join(projectDir, "pyproject.toml"))) {
670
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
671
+ }
672
+ } catch {
673
+ }
674
+ return { bin: "alembic", prefixArgs: [] };
658
675
  }
659
676
  function spawnAlembic(projectDir, args, dsn) {
660
677
  return new Promise((resolve2, reject) => {
661
- const bin = resolveAlembicBin(projectDir);
678
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
662
679
  const env = { ...process.env };
663
680
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path5.delimiter);
664
681
  if (dsn) env.DATABASE_URL = dsn;
665
- const child = spawn(bin, args, {
682
+ const child = spawn(bin, [...prefixArgs, ...args], {
666
683
  cwd: projectDir,
667
684
  env,
668
685
  stdio: ["ignore", "pipe", "pipe"]
@@ -2137,7 +2154,7 @@ async function reconcileTierBranch(args, deps = {}) {
2137
2154
  // scripts/lakebase/scm-workflow-state.ts
2138
2155
  import * as fs13 from "fs";
2139
2156
  import * as path14 from "path";
2140
- import { execFileSync as execFileSync3 } from "child_process";
2157
+ import { execFileSync as execFileSync4 } from "child_process";
2141
2158
  var SCM_STATES = [
2142
2159
  "scaffold-complete",
2143
2160
  "feature-claimed",
@@ -1667,8 +1667,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1667
1667
  var cached;
1668
1668
  function substrateSelfVersion() {
1669
1669
  if (cached !== void 0) return cached;
1670
- if ("0.3.1".length > 0) {
1671
- cached = "0.3.1";
1670
+ if ("0.3.3".length > 0) {
1671
+ cached = "0.3.3";
1672
1672
  return cached;
1673
1673
  }
1674
1674
  cached = "unknown";
@@ -1630,8 +1630,8 @@ var PKG_NAME = "@databricks-solutions/lakebase-scm-utils";
1630
1630
  var cached;
1631
1631
  function substrateSelfVersion() {
1632
1632
  if (cached !== void 0) return cached;
1633
- if ("0.3.1".length > 0) {
1634
- cached = "0.3.1";
1633
+ if ("0.3.3".length > 0) {
1634
+ cached = "0.3.3";
1635
1635
  return cached;
1636
1636
  }
1637
1637
  cached = "unknown";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@databricks-solutions/lakebase-scm-utils",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Lakebase SCM + substrate engine: database branching, paired-branch SCM workflows, connection/credential minting, schema migration, project scaffold + deploy primitives, and the git/github utility layer. Extracted from consort for portable, standalone consumption by lakebase-scm-extension and the SFTDD orchestration.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -14,7 +14,7 @@
14
14
  // stdout/stderr is not load-bearing here, which keeps the runner robust
15
15
  // to logger config drift in the consumer's `alembic.ini`.
16
16
 
17
- import { spawn } from "node:child_process";
17
+ import { spawn, execFileSync } from "node:child_process";
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
20
  import {
@@ -32,26 +32,70 @@ interface RunnerCtx {
32
32
  }
33
33
 
34
34
  /**
35
- * Resolve the `alembic` binary path. uv-managed Python projects install
36
- * alembic into a per-project `.venv/bin/alembic`, which is NOT on the
37
- * runner's PATH. Spawning bare `alembic` fails with ENOENT in CI even
38
- * after `uv sync` succeeded. Prefer the project-local venv when it
39
- * exists; fall back to bare `alembic` for projects with a pre-activated
40
- * shell venv or a system-wide install.
35
+ * Whether `uv` is on PATH. Memoized: resolution runs per alembic subcommand and
36
+ * the answer cannot change within a process.
41
37
  */
42
- export function resolveAlembicBin(projectDir: string): string {
43
- const candidates = [
38
+ let uvOnPath: boolean | undefined;
39
+ function hasUv(): boolean {
40
+ if (uvOnPath !== undefined) return uvOnPath;
41
+ try {
42
+ execFileSync("uv", ["--version"], { stdio: "ignore" });
43
+ uvOnPath = true;
44
+ } catch {
45
+ uvOnPath = false;
46
+ }
47
+ return uvOnPath;
48
+ }
49
+
50
+ /**
51
+ * Resolve HOW to invoke alembic in a project: an executable plus the args that
52
+ * must PREFIX the alembic subcommand.
53
+ *
54
+ * uv-managed Python projects install alembic into a per-project `.venv/bin/alembic`,
55
+ * which is NOT on the runner's PATH, so that venv-local bin is preferred when it
56
+ * exists (it is also what CI has after `uv sync`). But a FRESH clone / rehydrated
57
+ * project has no `.venv` yet (nothing has run `uv sync`), and spawning bare
58
+ * `alembic` then fails ENOENT — the failure the `lk lakebase-new-migration` path
59
+ * hit on a never-synced project, before any test run created the venv. For such a
60
+ * uv project (pyproject.toml present and `uv` on PATH) invoke via `uv run alembic`,
61
+ * which auto-creates the venv and syncs the declared deps (alembic is a top-level
62
+ * dep) BEFORE running — exactly what scripts/run-tests.sh relies on. CI is
63
+ * unaffected: it hits the venv-local bin first. Last resort is bare `alembic`
64
+ * (a pre-activated shell venv or a system-wide install).
65
+ */
66
+ export function resolveAlembicInvocation(
67
+ projectDir: string,
68
+ uvAvailable: boolean = hasUv()
69
+ ): { bin: string; prefixArgs: string[] } {
70
+ const venvCandidates = [
44
71
  path.join(projectDir, ".venv", "bin", "alembic"),
45
72
  path.join(projectDir, "venv", "bin", "alembic"),
46
73
  ];
47
- for (const candidate of candidates) {
74
+ for (const candidate of venvCandidates) {
48
75
  try {
49
- if (fs.existsSync(candidate)) return candidate;
76
+ if (fs.existsSync(candidate)) return { bin: candidate, prefixArgs: [] };
50
77
  } catch {
51
78
  // best-effort; keep checking
52
79
  }
53
80
  }
54
- return "alembic";
81
+ try {
82
+ if (uvAvailable && fs.existsSync(path.join(projectDir, "pyproject.toml"))) {
83
+ return { bin: "uv", prefixArgs: ["run", "alembic"] };
84
+ }
85
+ } catch {
86
+ // best-effort; fall through to bare alembic
87
+ }
88
+ return { bin: "alembic", prefixArgs: [] };
89
+ }
90
+
91
+ /**
92
+ * Back-compat: the executable that would run alembic (the venv-local path or bare
93
+ * `alembic`). Prefer resolveAlembicInvocation, which also models the `uv run`
94
+ * prefix for a never-synced uv project.
95
+ */
96
+ export function resolveAlembicBin(projectDir: string): string {
97
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
98
+ return prefixArgs.length ? `${bin} ${prefixArgs.join(" ")}` : bin;
55
99
  }
56
100
 
57
101
  /**
@@ -65,7 +109,7 @@ function spawnAlembic(
65
109
  dsn?: string
66
110
  ): Promise<{ stdout: string; stderr: string }> {
67
111
  return new Promise((resolve, reject) => {
68
- const bin = resolveAlembicBin(projectDir);
112
+ const { bin, prefixArgs } = resolveAlembicInvocation(projectDir);
69
113
  // Put the project root on PYTHONPATH so `app` is importable for EVERY alembic
70
114
  // subcommand. `upgrade` runs env.py (which prepends sys.path), but `history`
71
115
  // and other read-only commands do not, yet they still import every migration
@@ -74,7 +118,7 @@ function spawnAlembic(
74
118
  const env: NodeJS.ProcessEnv = { ...process.env };
75
119
  env.PYTHONPATH = [projectDir, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter);
76
120
  if (dsn) env.DATABASE_URL = dsn;
77
- const child = spawn(bin, args, {
121
+ const child = spawn(bin, [...prefixArgs, ...args], {
78
122
  cwd: projectDir,
79
123
  env,
80
124
  stdio: ["ignore", "pipe", "pipe"],
@@ -1,21 +1,57 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
2
  import { existsSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { join } from "node:path";
4
5
 
6
+ // The backend lives at the project root, one level up from this client/ config
7
+ // (Playwright runs from client/, hence cwd:".." on the webServers below).
8
+ const ROOT = join(process.cwd(), "..");
9
+
5
10
  // End-to-end tests drive the rendered SPA through a real browser against the
6
11
  // real backend, backed by the paired Lakebase branch. Playwright boots BOTH
7
12
  // servers: the backend FIRST (so Vite's /api proxy has a target), then Vite.
8
13
  // Adapt the backend `command`/`url` to your stack if it is not Python/FastAPI.
9
14
  //
10
- // Resiliency: the ports are env-driven (E2E_BACKEND_PORT / E2E_CLIENT_PORT) with
11
- // the conventional defaults. In CI (reuseExistingServer:false), a stale server
12
- // left on 8000/5173 by a prior run would otherwise make webServer hard-fail with
13
- // "port already used". So CI's E2E step allocates FREE ports (scripts/port-utils.sh,
14
- // the same probe run-dev.sh uses) and hands them in here , the run moves off a
15
- // busy port instead of colliding. The Vite proxy is pointed at the resolved
16
- // backend port via VITE_PROXY_TARGET so /api + /health still reach the backend.
17
- const BACKEND_PORT = process.env.E2E_BACKEND_PORT ?? "8000";
18
- const CLIENT_PORT = process.env.E2E_CLIENT_PORT ?? "5173";
15
+ // Resiliency: the ports are env-driven (E2E_BACKEND_PORT / E2E_CLIENT_PORT).
16
+ // run-tests.sh and CI pre-allocate FREE ports (scripts/port-utils.sh) and hand them
17
+ // in via those vars, so a stale server left on 8000/5173 by a prior run never makes
18
+ // webServer hard-fail with "port already used". When NOTHING hands them in — a direct
19
+ // `npx playwright test` (e.g. the build driver running a targeted spec, not through
20
+ // run-tests.sh) — resolve a free port HERE the SAME way, by sourcing that one shared
21
+ // free_port helper, so a direct run is just as collision-proof and never defaults onto
22
+ // a busy :8000 / :5173. One free-port owner (port-utils.sh), two entry points. The Vite
23
+ // proxy is pointed at the resolved backend port via VITE_PROXY_TARGET so /api + /health
24
+ // still reach the backend wherever it landed.
25
+ function resolvePort(envVar: string, fallback: number): string {
26
+ const fromEnv = process.env[envVar];
27
+ if (fromEnv) return fromEnv;
28
+ let resolved = String(fallback);
29
+ const portUtils = join(ROOT, "scripts", "port-utils.sh");
30
+ if (existsSync(portUtils)) {
31
+ try {
32
+ const out = execFileSync(
33
+ "bash",
34
+ ["-c", `source ${JSON.stringify(portUtils)} && free_port ${fallback}`],
35
+ { encoding: "utf8" }
36
+ ).trim();
37
+ if (/^\d+$/.test(out)) resolved = out;
38
+ } catch {
39
+ // fall through to the conventional default
40
+ }
41
+ }
42
+ // PERSIST the chosen port into the environment so Playwright's WORKER processes reuse it.
43
+ // Playwright evaluates this config in the main process AND re-evaluates it in each test
44
+ // worker. Without this, a worker re-runs resolvePort with E2E_CLIENT_PORT still unset, finds
45
+ // the port the main process picked (e.g. 5173) now TAKEN by the Vite webServer, allocates a
46
+ // DIFFERENT free port (5174), and points `baseURL` at 5174 where nothing listens — so the
47
+ // browser specs fail "not reachable" even though the app is healthy on the real port. Workers
48
+ // are spawned AFTER the main-process config load and inherit its process.env, so recording the
49
+ // resolution here makes every worker read it back via `fromEnv` above: resolve once, share it.
50
+ process.env[envVar] = resolved;
51
+ return resolved;
52
+ }
53
+ const BACKEND_PORT = resolvePort("E2E_BACKEND_PORT", 8000);
54
+ const CLIENT_PORT = resolvePort("E2E_CLIENT_PORT", 5173);
19
55
  // Use 127.0.0.1, NOT localhost, and bind both servers to it (below). `localhost`
20
56
  // is ambiguous: on macOS it resolves to IPv6 ::1 first, but uvicorn binds IPv4
21
57
  // 127.0.0.1 only (its default) and Vite's dev server is likewise IPv4 — so a
@@ -31,7 +67,6 @@ const CLIENT_URL = `http://127.0.0.1:${CLIENT_PORT}`;
31
67
  // one level up from this client/ config (Playwright runs from client/, hence cwd:".." below). Pick
32
68
  // the migrate-before-serve command + health URL for the detected stack so E2E boots on ANY language
33
69
  // , a nodejs scaffold otherwise inherited the Python (alembic/uvicorn) command and could not start.
34
- const ROOT = join(process.cwd(), "..");
35
70
  function backendWebServer(): { command: string; url: string } {
36
71
  if (existsSync(join(ROOT, "pom.xml"))) {
37
72
  // Java/Kotlin (Spring): flyway migrate, then boot on the resolved port.
@@ -118,9 +118,9 @@ class Base(DeclarativeBase):
118
118
  pass
119
119
 
120
120
 
121
- def get_db():
122
- db = SessionLocal()
123
- try:
124
- yield db
125
- finally:
126
- db.close()
121
+ # The request-scoped session DEPENDENCY (`get_session`) lives in the repository layer,
122
+ # at `app/repositories/session.py` — NOT here. This module is pure infrastructure (engine,
123
+ # SessionLocal, Base); keeping the per-request session door out of it is what lets a
124
+ # service-backed feature's boundary stay off infrastructure (the layering contract). A route
125
+ # that needs a session depends on the session a SERVICE exposes (sourced from the repository
126
+ # layer), never `from app.database import ...` a session dependency.
@@ -0,0 +1,8 @@
1
+ """Repository layer: the ONLY layer that touches the ORM / database session.
2
+
3
+ Each feature's repositories (data access for one aggregate) live here, alongside
4
+ the shared request-session door `session.get_session`. Business logic lives in the
5
+ service layer above; persistence lives only here. A service-backed feature's
6
+ boundary (routes) therefore never imports the DB session from infrastructure
7
+ (`app.database`) — it depends on a session the service exposes, sourced here.
8
+ """