@databricks-solutions/lakebase-scm-utils 0.3.2 → 0.3.4

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 (53) hide show
  1. package/dist/scripts/index.cjs +2 -2
  2. package/dist/scripts/index.js +2 -2
  3. package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +2 -2
  4. package/dist/scripts/lakebase/ci-resolve-branch.cli.js +2 -2
  5. package/dist/scripts/lakebase/collapse-heads.cli.cjs +2 -2
  6. package/dist/scripts/lakebase/collapse-heads.cli.js +2 -2
  7. package/dist/scripts/lakebase/detect-language.cli.cjs +2 -2
  8. package/dist/scripts/lakebase/detect-language.cli.js +2 -2
  9. package/dist/scripts/lakebase/doctor.cli.cjs +2 -2
  10. package/dist/scripts/lakebase/doctor.cli.js +2 -2
  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 +2 -2
  14. package/dist/scripts/lakebase/index.js +2 -2
  15. package/dist/scripts/lakebase/infra-runner.cli.cjs +2 -2
  16. package/dist/scripts/lakebase/infra-runner.cli.js +2 -2
  17. package/dist/scripts/lakebase/new-migration.cli.cjs +2 -2
  18. package/dist/scripts/lakebase/new-migration.cli.js +2 -2
  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 +2 -2
  22. package/dist/scripts/lakebase/schema-migrate.cli.js +2 -2
  23. package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +2 -2
  24. package/dist/scripts/lakebase/scm-claim-feature.cli.js +2 -2
  25. package/dist/scripts/lakebase/scm-doctor.cli.cjs +2 -2
  26. package/dist/scripts/lakebase/scm-doctor.cli.js +2 -2
  27. package/dist/scripts/lakebase/scm-merge.cli.cjs +2 -2
  28. package/dist/scripts/lakebase/scm-merge.cli.js +2 -2
  29. package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +2 -2
  30. package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +2 -2
  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/templates/project/client/consort-vitest-reporter.ts +96 -0
  35. package/templates/project/client/package.json +1 -0
  36. package/templates/project/client/playwright.config.ts +18 -3
  37. package/templates/project/client/tests/e2e/about.spec.ts +1 -1
  38. package/templates/project/client/tests/e2e/consort-fixtures.ts +23 -0
  39. package/templates/project/client/tests/e2e/home.spec.ts +1 -1
  40. package/templates/project/client/tests/setup.ts +79 -4
  41. package/templates/project/client/tsconfig.json +1 -1
  42. package/templates/project/client/vite.config.ts +8 -0
  43. package/templates/project/client/vitest.globalSetup.ts +121 -0
  44. package/templates/project/common/consort-capture.ts +87 -0
  45. package/templates/project/common/consort-test-reporter.ts +45 -0
  46. package/templates/project/common/playwright.config.ts +6 -1
  47. package/templates/project/python/app/consort_capture.py +130 -0
  48. package/templates/project/python/app/database.py +6 -6
  49. package/templates/project/python/app/main.py +52 -0
  50. package/templates/project/python/app/repositories/__init__.py +8 -0
  51. package/templates/project/python/app/repositories/session.py +28 -0
  52. package/templates/project/python/pyproject.toml +1 -0
  53. package/templates/project/python/tests/conftest.py +132 -3
@@ -1,10 +1,85 @@
1
- // Vitest global setup: extends expect() with jest-dom matchers and clears the
2
- // DOM + mocks between tests so each test starts from a known state.
1
+ // Vitest setup: extends expect() with jest-dom matchers and clears the DOM between tests.
2
+ //
3
+ // Kit principle — never mock when a real counterpart exists: component tests hit the REAL backend
4
+ // over the REAL `/api` boundary. jsdom has no origin, so a relative `fetch("/api/stock")` throws
5
+ // "Failed to parse URL"; this shim prefixes relative `/api` + `/health` with the backend the global
6
+ // setup (`vitest.globalSetup.ts`) booted and published to `.consort/.vitest-backend`. The component
7
+ // still only speaks HTTP — it never touches the DB. When no backend was booted (no base file) the
8
+ // shim is inert, so a pure-presentation component test is unaffected.
3
9
  import "@testing-library/jest-dom/vitest";
4
- import { afterEach, vi } from "vitest";
10
+ import { afterEach, beforeEach, vi } from "vitest";
5
11
  import { cleanup } from "@testing-library/react";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { captureTablesFromRoute } from "../../consort-capture";
6
15
 
7
- afterEach(() => {
16
+ const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
17
+
18
+ // The @T<id> of the test currently running, so the fetch shim can tag each /api request with it (the
19
+ // backend attributes the rows the request RETRIEVES/STORED to this test — app/consort_capture.py).
20
+ let currentTestId: string | null = null;
21
+ beforeEach((ctx: { task?: { name?: string } }) => {
22
+ currentTestId = TEST_ID.exec(ctx?.task?.name ?? "")?.[1] ?? null;
23
+ });
24
+
25
+ function backendBase(): string {
26
+ let d = process.cwd();
27
+ for (let i = 0; i < 8; i++) {
28
+ try {
29
+ return readFileSync(join(d, ".consort", ".vitest-backend"), "utf8").trim();
30
+ } catch {
31
+ /* walk up to the project root */
32
+ }
33
+ const up = dirname(d);
34
+ if (up === d) break;
35
+ d = up;
36
+ }
37
+ return "";
38
+ }
39
+
40
+ const BACKEND_BASE = backendBase();
41
+ if (BACKEND_BASE) {
42
+ const realFetch = globalThis.fetch.bind(globalThis);
43
+ const isApi = (u: string): boolean => u.startsWith("/api") || u.startsWith("/health");
44
+ const reroute = (url: string): string => (isApi(url) ? BACKEND_BASE + url : url);
45
+ // Add the X-Consort-Test-Id header to each /api request so the backend's per-test capture can key
46
+ // the rows this request reads/writes to the running test.
47
+ const tag = (url: string, init?: RequestInit): RequestInit | undefined => {
48
+ if (!currentTestId || !isApi(url)) return init;
49
+ const headers = new Headers(init?.headers);
50
+ headers.set("X-Consort-Test-Id", currentTestId);
51
+ return { ...init, headers };
52
+ };
53
+ globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
54
+ if (typeof input === "string") return realFetch(reroute(input), tag(input, init));
55
+ if (input instanceof URL) {
56
+ const u = input.toString();
57
+ return realFetch(reroute(u), tag(u, init));
58
+ }
59
+ if (input instanceof Request && input.url.startsWith("/")) {
60
+ const r = new Request(reroute(input.url), input);
61
+ if (currentTestId && isApi(input.url)) r.headers.set("X-Consort-Test-Id", currentTestId);
62
+ return realFetch(r, init);
63
+ }
64
+ return realFetch(input, init);
65
+ }) as typeof fetch;
66
+ }
67
+
68
+ // Per-test table-state capture lives HERE (the worker), not in the reporter: `BACKEND_BASE` is
69
+ // resolved in the worker after globalSetup booted the backend (same read the shim above proved
70
+ // works), whereas the reporter runs in the MAIN process where that base did not resolve — so the
71
+ // reporter's capture silently no-op'd. Running it in afterEach is also version-independent (always
72
+ // fires) and has the current test in context. Captures the DB state THIS component test left, keyed
73
+ // by its @T<id>, via the app's snapshot route (shared writer with the pytest/Playwright reporters).
74
+ afterEach(async (ctx: { task?: { name?: string; file?: { filepath?: string } } }) => {
8
75
  cleanup();
9
76
  vi.restoreAllMocks();
77
+ if (!BACKEND_BASE) return;
78
+ const id = TEST_ID.exec(ctx?.task?.name ?? "")?.[1];
79
+ if (!id) return;
80
+ try {
81
+ await captureTablesFromRoute(BACKEND_BASE, id, ctx?.task?.file?.filepath ?? "");
82
+ } catch {
83
+ /* best-effort: a snapshot write must never fail the test */
84
+ }
10
85
  });
@@ -15,7 +15,7 @@
15
15
  "noUnusedLocals": true,
16
16
  "noUnusedParameters": true,
17
17
  "noFallthroughCasesInSwitch": true,
18
- "types": ["vitest/globals", "@testing-library/jest-dom"]
18
+ "types": ["vitest/globals", "@testing-library/jest-dom", "node"]
19
19
  },
20
20
  "include": ["src", "tests/setup.ts"]
21
21
  }
@@ -33,6 +33,14 @@ export default defineConfig({
33
33
  environment: "jsdom",
34
34
  globals: true,
35
35
  setupFiles: "./tests/setup.ts",
36
+ // Boot a REAL backend (migrated, against the paired branch) for the whole component run, so
37
+ // component tests hit the real `/api` service — never a mock (kit principle). The fetch shim in
38
+ // tests/setup.ts routes relative `/api` to it. Torn down after. See vitest.globalSetup.ts.
39
+ globalSetup: "./vitest.globalSetup.ts",
40
+ // Keep Vitest's own `default` reporter for humans, and add the Consort per-test reporter:
41
+ // it appends each `@T<id>`-named test to `.consort/test-run.jsonl` as it finishes, so the
42
+ // dashboard lights client tests live alongside the backend (pytest) and E2E (Playwright) ones.
43
+ reporters: ["default", "./consort-vitest-reporter.ts"],
36
44
  include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"],
37
45
  exclude: [...configDefaults.exclude, "tests/e2e/**"],
38
46
  css: false,
@@ -0,0 +1,121 @@
1
+ // Vitest global setup — boot a REAL backend for the component suite.
2
+ //
3
+ // Kit principle: never mock when a real counterpart exists. Client component tests must hit the
4
+ // REAL backend over the REAL `/api` boundary (which hits the real paired-branch DB), exactly like
5
+ // production — the client never gets direct DB access. This mirrors `playwright.config.ts`'s backend
6
+ // webServer: migrate the branch, serve the app on a free 127.0.0.1 port, wait for health, and
7
+ // publish the base URL to `.consort/.vitest-backend` for the fetch shim in `tests/setup.ts`. Torn
8
+ // down after the run. Language-aware (python / node / java), same detection as the Playwright config.
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
+ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
11
+ import { createServer } from "node:net";
12
+ import { join } from "node:path";
13
+
14
+ const ROOT = join(process.cwd(), ".."); // vitest runs from client/
15
+ const CLIENT = join(ROOT, "client");
16
+ const BASE_FILE = join(ROOT, ".consort", ".vitest-backend");
17
+
18
+ // True iff the project has at least one component test (`*.test.tsx|ts` under client/src or
19
+ // client/tests, excluding the Playwright `tests/e2e/` dir). When there are none, booting a backend
20
+ // is pure waste and an avoidable failure surface — so the setup is a no-op (returns early). This is
21
+ // what keeps a component-less project (e.g. one whose UI is all Playwright E2E) from paying for, or
22
+ // failing on, a backend boot it never uses.
23
+ function hasComponentTests(): boolean {
24
+ const stack: string[] = [join(CLIENT, "src"), join(CLIENT, "tests")];
25
+ while (stack.length) {
26
+ const dir = stack.pop()!;
27
+ let entries: string[];
28
+ try {
29
+ entries = readdirSync(dir);
30
+ } catch {
31
+ continue;
32
+ }
33
+ for (const e of entries) {
34
+ const abs = join(dir, e);
35
+ if (abs.replace(/\\/g, "/").includes("/tests/e2e")) continue; // Playwright's, not Vitest's
36
+ let st;
37
+ try {
38
+ st = statSync(abs);
39
+ } catch {
40
+ continue;
41
+ }
42
+ if (st.isDirectory()) stack.push(abs);
43
+ else if (/\.test\.(ts|tsx|js|jsx)$/.test(e)) return true;
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ function freePort(): Promise<number> {
50
+ return new Promise((resolve, reject) => {
51
+ const srv = createServer();
52
+ srv.on("error", reject);
53
+ srv.listen(0, "127.0.0.1", () => {
54
+ const addr = srv.address();
55
+ const port = typeof addr === "object" && addr ? addr.port : 0;
56
+ srv.close(() => resolve(port));
57
+ });
58
+ });
59
+ }
60
+
61
+ // migrate-before-serve command + health URL for the detected stack (mirrors the Playwright config).
62
+ function backend(port: number): { shell: string; health: string } {
63
+ if (existsSync(join(ROOT, "pom.xml"))) {
64
+ return {
65
+ shell: `./mvnw -q flyway:migrate && ./mvnw -q spring-boot:run -Dspring-boot.run.arguments=--server.port=${port}`,
66
+ health: `http://127.0.0.1:${port}/actuator/health`,
67
+ };
68
+ }
69
+ if (
70
+ existsSync(join(ROOT, "package.json")) &&
71
+ !existsSync(join(ROOT, "pyproject.toml")) &&
72
+ !existsSync(join(ROOT, "requirements.txt"))
73
+ ) {
74
+ return { shell: `npm run migrate && PORT=${port} npm run start`, health: `http://127.0.0.1:${port}/health` };
75
+ }
76
+ return {
77
+ shell: `uv run alembic upgrade head && uv run uvicorn app.main:app --host 127.0.0.1 --port ${port}`,
78
+ health: `http://127.0.0.1:${port}/health`,
79
+ };
80
+ }
81
+
82
+ let proc: ChildProcess | undefined;
83
+
84
+ export default async function setup(ctx?: { provide?: (k: string, v: unknown) => void }): Promise<() => Promise<void>> {
85
+ if (!hasComponentTests()) {
86
+ // No component tests → nothing to serve a backend for. No-op teardown.
87
+ return async () => {};
88
+ }
89
+ const port = await freePort();
90
+ const base = `http://127.0.0.1:${port}`;
91
+ const { shell, health } = backend(port);
92
+ proc = spawn("bash", ["-c", shell], { cwd: ROOT, stdio: "ignore", env: { ...process.env } });
93
+
94
+ const deadline = Date.now() + 120_000;
95
+ for (;;) {
96
+ try {
97
+ const r = await fetch(health);
98
+ if (r.ok) break;
99
+ } catch {
100
+ /* backend not up yet */
101
+ }
102
+ if (Date.now() > deadline) {
103
+ if (proc && !proc.killed) proc.kill("SIGTERM");
104
+ throw new Error(`vitest: backend did not become healthy at ${health} within 120s`);
105
+ }
106
+ await new Promise((r) => setTimeout(r, 500));
107
+ }
108
+
109
+ mkdirSync(join(ROOT, ".consort"), { recursive: true });
110
+ writeFileSync(BASE_FILE, base); // the fetch shim reads this (pool-agnostic)
111
+ ctx?.provide?.("consortApiBase", base);
112
+
113
+ return async () => {
114
+ try {
115
+ rmSync(BASE_FILE);
116
+ } catch {
117
+ /* already gone */
118
+ }
119
+ if (proc && !proc.killed) proc.kill("SIGTERM");
120
+ };
121
+ }
@@ -0,0 +1,87 @@
1
+ // Consort per-test capture writers — FRAMEWORK-AGNOSTIC (no Playwright, no Vitest import).
2
+ //
3
+ // These are the shared, pure Node helpers the three test reporters use to stream per-test signals to
4
+ // `.consort/`: the status trail (`test-run.jsonl`) the dashboard tails to light bubbles, and the
5
+ // per-test table snapshots (`table-snapshots.jsonl`) the dashboard reads for its table view. They
6
+ // live in their OWN module (not in `consort-test-reporter.ts`) precisely because that file imports
7
+ // `@playwright/test/reporter` for the Playwright Reporter class — and the Vitest client compile
8
+ // (`tests/setup.ts` captures table state in an afterEach) must NOT drag Playwright's types through
9
+ // the client's bundler-resolution tsconfig. Importing these from here keeps the client Playwright-free.
10
+ //
11
+ // Pure observability: everything is wrapped so a write can NEVER fail a test/suite.
12
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ /** Walk up from `start` to the project's `.consort` dir (tests run from root OR client/, so cwd
16
+ * varies); fall back to `<cwd>/.consort` (created on write) if none is found. */
17
+ export function findConsortDir(start: string): string {
18
+ let d = start;
19
+ for (let i = 0; i < 8; i++) {
20
+ if (existsSync(join(d, ".consort"))) return join(d, ".consort");
21
+ const up = dirname(d);
22
+ if (up === d) break;
23
+ d = up;
24
+ }
25
+ return join(start, ".consort");
26
+ }
27
+
28
+ export type TestRunStatus = "running" | "green" | "red" | "skipped";
29
+
30
+ export function emitTestRun(id: string, status: TestRunStatus, absFile: string, kind: string): void {
31
+ try {
32
+ const dir = findConsortDir(process.cwd());
33
+ mkdirSync(dir, { recursive: true });
34
+ const root = dirname(dir);
35
+ const file = (absFile || "").replace(`${root}/`, "");
36
+ appendFileSync(
37
+ join(dir, "test-run.jsonl"),
38
+ JSON.stringify({ ts: new Date().toISOString(), test_id: id, status, file, kind }) + "\n",
39
+ );
40
+ } catch {
41
+ /* best-effort: a per-test reporter write must never fail the suite */
42
+ }
43
+ }
44
+
45
+ /** Append a per-test TABLE snapshot (one line per table) to `.consort/table-snapshots.jsonl`, keyed
46
+ * by test id — parallel to `emitTestRun`. Best-effort. */
47
+ export function emitTableSnapshot(id: string, absFile: string, table: string, rows: unknown[], rowCount: number | null): void {
48
+ try {
49
+ const dir = findConsortDir(process.cwd());
50
+ mkdirSync(dir, { recursive: true });
51
+ const root = dirname(dir);
52
+ const file = (absFile || "").replace(`${root}/`, "");
53
+ appendFileSync(
54
+ join(dir, "table-snapshots.jsonl"),
55
+ JSON.stringify({ ts: new Date().toISOString(), test_id: id, file, table, rows, row_count: rowCount }) + "\n",
56
+ );
57
+ } catch {
58
+ /* best-effort */
59
+ }
60
+ }
61
+
62
+ /** Capture the DB state a test left — for the dashboard's per-test table view — by asking the app's
63
+ * read-only `/api/_consort/table-snapshot` route (the client never touches the DB; the app, which the
64
+ * test hit over `/api`, reports its rows). Writes one `emitTableSnapshot` per table, keyed by test id.
65
+ * Shared by the Playwright (e2e) AND Vitest (component) paths — both drive the DB THROUGH the app.
66
+ * Best-effort + time-boxed: a missing route (prod app) or a slow/unreachable endpoint just skips it. */
67
+ export async function captureTablesFromRoute(baseURL: string, id: string, absFile: string): Promise<void> {
68
+ if (!baseURL) return;
69
+ try {
70
+ // Time-box with Promise.race, NOT an AbortController signal: this runs in BOTH Node (Playwright /
71
+ // pytest reporters) and jsdom (Vitest component afterEach), and a jsdom-realm AbortSignal is not an
72
+ // instance of the AbortSignal Node's undici fetch expects ("Expected signal to be an instance of
73
+ // AbortSignal") — it throws there and the capture silently no-ops. A dangling GET on timeout is a
74
+ // harmless read-only best-effort call.
75
+ const res = await Promise.race([
76
+ fetch(`${baseURL.replace(/\/$/, "")}/api/_consort/table-snapshot`),
77
+ new Promise<Response>((_, reject) => setTimeout(() => reject(new Error("snapshot timeout")), 4000)),
78
+ ]);
79
+ if (!res.ok) return;
80
+ const tables = (await res.json()) as Record<string, { rows?: unknown[]; row_count?: number | null }>;
81
+ for (const [table, data] of Object.entries(tables)) {
82
+ emitTableSnapshot(id, absFile, table, data.rows ?? [], data.row_count ?? null);
83
+ }
84
+ } catch {
85
+ /* route absent (prod) / unreachable / timed out — skip the snapshot */
86
+ }
87
+ }
@@ -0,0 +1,45 @@
1
+ // Consort per-test "green as it runs" reporter for Playwright.
2
+ //
3
+ // Appends one line to `.consort/test-run.jsonl` as each test tagged `@T<id>` in its title finishes,
4
+ // so the dashboard tails it and lights that test live — even under story cadence (one whole-suite
5
+ // run), where otherwise every test would appear to flip at once. The `file` disambiguates a reused
6
+ // test id across features (F1 and F2 may both carry a T37); the dashboard keys by (scenario_file + id).
7
+ //
8
+ // The shared, framework-agnostic capture writers (emitTestRun / emitTableSnapshot /
9
+ // captureTablesFromRoute / findConsortDir) live in `./consort-capture` — this file is the ONLY one
10
+ // that imports `@playwright/test/reporter`, so the Vitest/client compile never pulls in Playwright.
11
+ // Re-exported here for back-compat with importers that still reach through this module.
12
+ //
13
+ // Pure observability: everything is wrapped so a reporter write can NEVER fail the suite. Tag a test
14
+ // by putting its test-list id in the title, e.g. `test("home table lists stock @T37", …)`.
15
+ import type { FullConfig, Reporter, TestCase, TestResult } from "@playwright/test/reporter";
16
+ import { emitTestRun, captureTablesFromRoute } from "./consort-capture";
17
+
18
+ export { findConsortDir, emitTestRun, emitTableSnapshot, captureTablesFromRoute, type TestRunStatus } from "./consort-capture";
19
+
20
+ const TEST_ID = /@(T\d+(?:-[a-z0-9-]+)?)/;
21
+
22
+ export default class ConsortTestReporter implements Reporter {
23
+ private baseURL = "";
24
+
25
+ // The app's read-only snapshot route lives at the app origin; E2E specs hit the DB THROUGH the
26
+ // app, so after each spec we ask the app what rows it left (the client never touches the DB).
27
+ onBegin(config: FullConfig): void {
28
+ this.baseURL = config.projects?.[0]?.use?.baseURL ?? "";
29
+ }
30
+
31
+ // Emit `running` the moment a tagged spec starts, so the dashboard shows it in-flight (amber)
32
+ // before it resolves — the dashboard's last-write-wins reduce makes running -> green compose.
33
+ onTestBegin(test: TestCase): void {
34
+ const id = TEST_ID.exec(test.title)?.[1];
35
+ if (id) emitTestRun(id, "running", test.location?.file ?? "", "client");
36
+ }
37
+
38
+ async onTestEnd(test: TestCase, result: TestResult): Promise<void> {
39
+ const id = TEST_ID.exec(test.title)?.[1];
40
+ if (!id) return;
41
+ const status = result.status === "passed" ? "green" : result.status === "skipped" ? "skipped" : "red";
42
+ emitTestRun(id, status, test.location?.file ?? "", "client");
43
+ await captureTablesFromRoute(this.baseURL, id, test.location?.file ?? "");
44
+ }
45
+ }
@@ -37,7 +37,12 @@ export default defineConfig({
37
37
  // network jitter against a freshly-provisioned paired branch endpoint.
38
38
  retries: process.env.CI ? 1 : 0,
39
39
  workers: process.env.CI ? 2 : undefined,
40
- reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "list",
40
+ // `./consort-test-reporter.ts` appends each `@T<id>`-tagged test to `.consort/test-run.jsonl` as
41
+ // it finishes, so the Consort dashboard lights per-test progress live (see that file). It rides
42
+ // alongside the human reporters and never replaces them.
43
+ reporter: process.env.CI
44
+ ? [["html", { open: "never" }], ["list"], ["./consort-test-reporter.ts"]]
45
+ : [["list"], ["./consort-test-reporter.ts"]],
41
46
  use: {
42
47
  baseURL,
43
48
  trace: "on-first-retry",
@@ -0,0 +1,130 @@
1
+ """Per-test RETRIEVED/STORED row capture (dev/test/CI only) for the Consort dashboard.
2
+
3
+ The dashboard's per-test table view wants the rows each TEST actually touched — not the whole table.
4
+ Every test (pytest / Playwright / Vitest component) exercises the DB THROUGH this app over `/api`, so
5
+ this app is the one place that sees every query. Each test tags its requests with an
6
+ `X-Consort-Test-Id: T<id>` header; a middleware stashes that id in a contextvar for the request, and
7
+ two SQLAlchemy events record the rows that request touched, keyed by the test id:
8
+
9
+ * RETRIEVED — the ORM `load` event fires as each row is materialized into a mapped instance
10
+ (covers every `select(Model)` read path).
11
+ * STORED — the `after_cursor_execute` event fires for INSERT/UPDATE/DELETE; the written values
12
+ live in the statement's bound parameters (the app writes via `insert(...).returning()`
13
+ executed as a statement, which `after_flush`/unit-of-work would NOT see).
14
+
15
+ Each touched row is appended to `.consort/test-interactions.jsonl` as
16
+ `{ts, test_id, table, op: "read"|"write", row}`. Guarded by the PRESENCE of a `.consort/` dir (dev/
17
+ test/CI checkout, not a packaged prod app) exactly like the snapshot route — so this is simply absent
18
+ in a real deploy. Everything is wrapped so a capture write can NEVER fail a request.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextvars
24
+ import json
25
+ import os
26
+ import re
27
+ import threading
28
+ from datetime import datetime, timezone
29
+ from typing import Any
30
+
31
+ from sqlalchemy import event
32
+ from sqlalchemy.engine import Engine
33
+ from sqlalchemy.orm import DeclarativeBase
34
+
35
+ # The current request's test id (from the X-Consort-Test-Id header), and the per-request buffer of
36
+ # touched rows. ContextVars propagate into Starlette's sync-endpoint threadpool, so the SQLAlchemy
37
+ # events (which fire on the DB call within the handler) see the id the middleware set.
38
+ _TEST_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar("consort_test_id", default=None)
39
+ _BUFFER: contextvars.ContextVar[list[dict[str, Any]] | None] = contextvars.ContextVar("consort_buffer", default=None)
40
+
41
+ _WRITE_LOCK = threading.Lock()
42
+ _ROWS_CAP = 200 # per (test, table, op) within one request — keep the log bounded
43
+ _DML = re.compile(r"^\s*(INSERT|UPDATE|DELETE)\b", re.IGNORECASE)
44
+ _TABLE = re.compile(r'(?:INTO|UPDATE|FROM)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?', re.IGNORECASE)
45
+
46
+
47
+ def _jsonable(v: Any) -> Any:
48
+ if isinstance(v, datetime):
49
+ return v.isoformat()
50
+ try:
51
+ json.dumps(v)
52
+ return v
53
+ except (TypeError, ValueError):
54
+ return str(v)
55
+
56
+
57
+ def _record(table: str, op: str, row: dict[str, Any]) -> None:
58
+ """Append a touched row to the current request's buffer (no-op outside a tagged request)."""
59
+ buf = _BUFFER.get()
60
+ if buf is None:
61
+ return
62
+ # Bound the buffer per (table, op) so a big list read can't explode the log.
63
+ n = sum(1 for e in buf if e["table"] == table and e["op"] == op)
64
+ if n >= _ROWS_CAP:
65
+ return
66
+ buf.append({"table": table, "op": op, "row": {k: _jsonable(v) for k, v in row.items()}})
67
+
68
+
69
+ def _flush(path: str, test_id: str, buf: list[dict[str, Any]]) -> None:
70
+ if not buf:
71
+ return
72
+ ts = datetime.now(timezone.utc).isoformat()
73
+ lines = "".join(
74
+ json.dumps({"ts": ts, "test_id": test_id, "table": e["table"], "op": e["op"], "row": e["row"]}) + "\n"
75
+ for e in buf
76
+ )
77
+ try:
78
+ with _WRITE_LOCK:
79
+ with open(path, "a", encoding="utf-8") as fh:
80
+ fh.write(lines)
81
+ except OSError:
82
+ pass # best-effort: a capture write must never fail a request
83
+
84
+
85
+ def install(app: Any, engine: Engine, base: type[DeclarativeBase]) -> None:
86
+ """Wire the middleware + SQLAlchemy events. No-op unless a `.consort/` dir is present."""
87
+ project_root = os.path.join(os.path.dirname(__file__), os.pardir)
88
+ consort_dir = os.path.join(project_root, ".consort")
89
+ if not os.path.isdir(consort_dir):
90
+ return
91
+ interactions_path = os.path.join(consort_dir, "test-interactions.jsonl")
92
+
93
+ @app.middleware("http")
94
+ async def _consort_tag_request(request: Any, call_next: Any) -> Any:
95
+ test_id = request.headers.get("x-consort-test-id")
96
+ id_token = _TEST_ID.set(test_id)
97
+ buf_token = _BUFFER.set([] if test_id else None)
98
+ try:
99
+ return await call_next(request)
100
+ finally:
101
+ buf = _BUFFER.get()
102
+ if test_id and buf:
103
+ _flush(interactions_path, test_id, buf)
104
+ _BUFFER.reset(buf_token)
105
+ _TEST_ID.reset(id_token)
106
+
107
+ @event.listens_for(base, "load", propagate=True)
108
+ def _consort_on_load(target: Any, _context: Any) -> None: # RETRIEVED
109
+ if _TEST_ID.get() is None:
110
+ return
111
+ try:
112
+ table = target.__tablename__
113
+ row = {c.key: getattr(target, c.key) for c in target.__table__.columns}
114
+ _record(table, "read", row)
115
+ except Exception: # noqa: BLE001 — capture must never break a load
116
+ pass
117
+
118
+ @event.listens_for(engine, "after_cursor_execute")
119
+ def _consort_on_execute(_conn, _cursor, statement, parameters, _context, _executemany) -> None: # STORED
120
+ if _TEST_ID.get() is None or not statement or not _DML.match(statement):
121
+ return
122
+ try:
123
+ m = _TABLE.search(statement)
124
+ table = m.group(1) if m else "?"
125
+ params_list = parameters if isinstance(parameters, (list, tuple)) else [parameters]
126
+ for p in params_list:
127
+ if isinstance(p, dict) and p:
128
+ _record(table, "write", dict(p))
129
+ except Exception: # noqa: BLE001
130
+ pass
@@ -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.
@@ -14,6 +14,58 @@ def health():
14
14
  return {"status": "ok"}
15
15
 
16
16
 
17
+ # ── Consort dashboard table-snapshot (dev/test/CI only) ─────────────────────
18
+ # A READ-ONLY peek at the current rows of each public table, for the Consort
19
+ # dashboard's per-test table view. E2E (Playwright) specs mutate the DB THROUGH
20
+ # the app, so the app is the one process already connected to the branch — the
21
+ # E2E reporter calls this after each spec to record the DB state that spec left.
22
+ # Guarded by the PRESENCE of a `.consort/` dir at the project root: present in a
23
+ # dev/test/CI checkout but NOT in a packaged production app, so the route is
24
+ # simply absent in a real deploy — no env flag, no per-server wiring. Read-only,
25
+ # row-capped, identifier-validated.
26
+ _PROJECT_ROOT = os.path.join(os.path.dirname(__file__), os.pardir)
27
+ if os.path.isdir(os.path.join(_PROJECT_ROOT, ".consort")):
28
+ import re as _re
29
+
30
+ from sqlalchemy import text as _sql_text
31
+
32
+ from app.database import engine as _snap_engine
33
+
34
+ _SNAP_IDENT = _re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
35
+ _SNAP_CAP = 50
36
+
37
+ @app.get("/api/_consort/table-snapshot")
38
+ def _consort_table_snapshot():
39
+ """Current rows of every public app table (except alembic_version), capped. Read-only."""
40
+ out: dict[str, dict] = {}
41
+ with _snap_engine.connect() as conn:
42
+ names = [
43
+ r[0]
44
+ for r in conn.execute(
45
+ _sql_text(
46
+ "select tablename from pg_tables where schemaname='public' "
47
+ "and tablename <> 'alembic_version' order by 1"
48
+ )
49
+ )
50
+ ]
51
+ for name in names:
52
+ if not _SNAP_IDENT.fullmatch(name):
53
+ continue
54
+ rows = conn.execute(_sql_text(f'SELECT * FROM "{name}" ORDER BY 1 DESC LIMIT {_SNAP_CAP}')).mappings().all()
55
+ total = conn.execute(_sql_text(f'SELECT count(*) FROM "{name}"')).scalar()
56
+ out[name] = {"rows": [dict(r) for r in rows], "row_count": total}
57
+ return out
58
+
59
+ # Per-test RETRIEVED/STORED row capture: records the rows each test's `/api` requests read/wrote
60
+ # (keyed by the X-Consort-Test-Id header) to `.consort/test-interactions.jsonl`, for the
61
+ # dashboard's per-test table view. Middleware + SQLAlchemy ORM `load`/DML events; same dev/test/CI
62
+ # guard as the snapshot route above. See app/consort_capture.py.
63
+ from app import consort_capture as _consort_capture
64
+ from app.database import Base as _consort_base
65
+
66
+ _consort_capture.install(app, _snap_engine, _consort_base)
67
+
68
+
17
69
  # ── Register API routes ABOVE this line ─────────────────────────────────────
18
70
  # Everything below serves the built React SPA (client/dist) in production, so a
19
71
  # single-process deploy (e.g. a Databricks App) serves both the API and the UI.
@@ -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
+ """
@@ -0,0 +1,28 @@
1
+ """Request-scoped database session — the repository layer's session door.
2
+
3
+ The ONE place a request obtains a SQLAlchemy `Session`. It lives in the REPOSITORY
4
+ layer (not infrastructure) on purpose: the layering contract is boundary -> service
5
+ -> repository, and ONLY the repository (and the engine in `app.database`) touches the
6
+ session. So a service-backed feature's route NEVER imports the session from
7
+ `app.database`; instead a service exposes `get_session` (re-exporting it) and the route
8
+ depends on that. A simple, non-layered feature may depend on `get_session` directly —
9
+ it is still outside infrastructure, so the boundary stays clean either way.
10
+
11
+ The engine + `SessionLocal` are built once in `app.database` (runtime-minted Lakebase
12
+ token per connection); this module only hands out and closes a session.
13
+ """
14
+
15
+ from collections.abc import Iterator
16
+
17
+ from sqlalchemy.orm import Session
18
+
19
+ from app.database import SessionLocal
20
+
21
+
22
+ def get_session() -> Iterator[Session]:
23
+ """Yield a database session and always close it (FastAPI `Depends` / `with` use)."""
24
+ db = SessionLocal()
25
+ try:
26
+ yield db
27
+ finally:
28
+ db.close()
@@ -33,4 +33,5 @@ dev = [
33
33
  # downgrade/upgrade cannot corrupt the shared verify DB for sibling tests.
34
34
  markers = [
35
35
  "migration: schema-mutating migration-reversibility test; runs on an isolated ephemeral branch",
36
+ "testid: the test-list id (e.g. @pytest.mark.testid(\"T37\")) this test realizes, for the dashboard's per-test live signal",
36
37
  ]