@databricks-solutions/lakebase-scm-utils 0.3.8 → 0.3.10
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 +59 -24
- package/dist/scripts/index.js +59 -24
- package/dist/scripts/lakebase/branch.cli.cjs +55 -10
- package/dist/scripts/lakebase/branch.cli.js +55 -10
- package/dist/scripts/lakebase/ci-resolve-branch.cli.cjs +59 -24
- package/dist/scripts/lakebase/ci-resolve-branch.cli.js +59 -24
- package/dist/scripts/lakebase/collapse-heads.cli.cjs +16 -14
- package/dist/scripts/lakebase/collapse-heads.cli.js +16 -14
- package/dist/scripts/lakebase/cut-backup.cli.cjs +43 -10
- package/dist/scripts/lakebase/cut-backup.cli.js +43 -10
- package/dist/scripts/lakebase/cut-tier.cli.cjs +43 -10
- package/dist/scripts/lakebase/cut-tier.cli.js +43 -10
- package/dist/scripts/lakebase/detect-language.cli.cjs +16 -14
- package/dist/scripts/lakebase/detect-language.cli.js +16 -14
- package/dist/scripts/lakebase/doctor.cli.cjs +16 -14
- package/dist/scripts/lakebase/doctor.cli.js +16 -14
- package/dist/scripts/lakebase/get-connection.cli.cjs +16 -14
- package/dist/scripts/lakebase/get-connection.cli.js +16 -14
- package/dist/scripts/lakebase/index.cjs +59 -24
- package/dist/scripts/lakebase/index.d.cts +23 -0
- package/dist/scripts/lakebase/index.d.ts +23 -0
- package/dist/scripts/lakebase/index.js +59 -24
- package/dist/scripts/lakebase/infra-runner.cli.cjs +16 -14
- package/dist/scripts/lakebase/infra-runner.cli.js +16 -14
- package/dist/scripts/lakebase/new-migration.cli.cjs +16 -14
- package/dist/scripts/lakebase/new-migration.cli.js +16 -14
- package/dist/scripts/lakebase/schema-diff.cli.cjs +16 -14
- package/dist/scripts/lakebase/schema-diff.cli.js +16 -14
- package/dist/scripts/lakebase/schema-migrate.cli.cjs +16 -14
- package/dist/scripts/lakebase/schema-migrate.cli.js +16 -14
- package/dist/scripts/lakebase/scm-claim-feature.cli.cjs +59 -24
- package/dist/scripts/lakebase/scm-claim-feature.cli.js +59 -24
- package/dist/scripts/lakebase/scm-doctor.cli.cjs +59 -24
- package/dist/scripts/lakebase/scm-doctor.cli.js +59 -24
- package/dist/scripts/lakebase/scm-merge.cli.cjs +16 -14
- package/dist/scripts/lakebase/scm-merge.cli.js +16 -14
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.cjs +16 -14
- package/dist/scripts/lakebase/scm-reconcile-tier.cli.js +16 -14
- package/dist/scripts/lakebase/scm-recover-orphans.cli.cjs +43 -10
- package/dist/scripts/lakebase/scm-recover-orphans.cli.js +43 -10
- package/dist/scripts/lakebase/snapshot.cli.cjs +418 -0
- package/dist/scripts/lakebase/snapshot.cli.d.cts +1 -0
- package/dist/scripts/lakebase/snapshot.cli.d.ts +1 -0
- package/dist/scripts/lakebase/snapshot.cli.js +395 -0
- package/dist/scripts/util/index.cjs +2 -2
- package/dist/scripts/util/index.js +2 -2
- package/package.json +2 -1
- package/scripts/lakebase/branch-create.ts +93 -14
- package/scripts/lakebase/branch.cli.ts +15 -0
- package/scripts/lakebase/get-connection.ts +29 -12
- package/scripts/lakebase/snapshot.cli.ts +117 -0
- package/scripts/lakebase/snapshot.ts +85 -0
- package/templates/project/common/scripts/run-tests.sh +43 -2
- package/templates/project/common/tests/e2e/conftest.py +5 -1
- package/templates/project/python/tests/conftest.py +60 -4
- package/templates/project/python/tests/__pycache__/conftest.cpython-314.pyc +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI for Lakebase SNAPSHOTS: `lakebase-snapshot <create|delete|list> ...`.
|
|
3
|
+
//
|
|
4
|
+
// A snapshot is an immutable point-in-time copy of a branch. Capture the
|
|
5
|
+
// committed schema once, then branch copy-on-write from it per verify cycle
|
|
6
|
+
// (`lakebase-branch create --from-snapshot <id>`) instead of re-running
|
|
7
|
+
// `alembic upgrade head`. Prints JSON on stdout, errors on stderr.
|
|
8
|
+
// 0 = success 1 = runtime error 2 = usage error
|
|
9
|
+
import { createSnapshot, deleteSnapshot, listSnapshots } from "./snapshot.js";
|
|
10
|
+
|
|
11
|
+
interface Args {
|
|
12
|
+
subcommand?: string;
|
|
13
|
+
instance?: string;
|
|
14
|
+
snapshot?: string;
|
|
15
|
+
branch?: string;
|
|
16
|
+
atTime?: string;
|
|
17
|
+
atLsn?: string;
|
|
18
|
+
ttl?: string;
|
|
19
|
+
noExpiry?: boolean;
|
|
20
|
+
host?: string;
|
|
21
|
+
pretty?: boolean;
|
|
22
|
+
help?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv: string[]): Args {
|
|
26
|
+
const out: Args = {};
|
|
27
|
+
let i = 0;
|
|
28
|
+
if (argv[0] && !argv[0].startsWith("-")) {
|
|
29
|
+
out.subcommand = argv[0];
|
|
30
|
+
i = 1;
|
|
31
|
+
}
|
|
32
|
+
for (; i < argv.length; i++) {
|
|
33
|
+
switch (argv[i]) {
|
|
34
|
+
case "--instance": out.instance = argv[++i]; break;
|
|
35
|
+
case "--snapshot": out.snapshot = argv[++i]; break;
|
|
36
|
+
case "--branch": out.branch = argv[++i]; break;
|
|
37
|
+
case "--at-time": out.atTime = argv[++i]; break;
|
|
38
|
+
case "--at-lsn": out.atLsn = argv[++i]; break;
|
|
39
|
+
case "--ttl": out.ttl = argv[++i]; break;
|
|
40
|
+
case "--no-expiry": out.noExpiry = true; break;
|
|
41
|
+
case "--host": out.host = argv[++i]; break;
|
|
42
|
+
case "--pretty": out.pretty = true; break;
|
|
43
|
+
case "--help": case "-h": out.help = true; break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const USAGE = `usage: lakebase-snapshot <create|delete|list> --instance <project> [flags]
|
|
50
|
+
create --snapshot <id> --branch <src> [--at-time <iso> | --at-lsn <lsn>] [--ttl <s> | --no-expiry]
|
|
51
|
+
delete --snapshot <id>
|
|
52
|
+
list
|
|
53
|
+
Then branch from it: lakebase-branch create --branch <v> --from-snapshot <id> --instance <project>`;
|
|
54
|
+
|
|
55
|
+
function out(result: unknown, pretty: boolean): void {
|
|
56
|
+
process.stdout.write(JSON.stringify(result, null, pretty ? 2 : undefined) + "\n");
|
|
57
|
+
}
|
|
58
|
+
function need(a: Args, keys: (keyof Args)[]): boolean {
|
|
59
|
+
const missing = keys.filter((k) => a[k] === undefined || a[k] === "");
|
|
60
|
+
if (missing.length) {
|
|
61
|
+
process.stderr.write(`missing ${missing.map((k) => "--" + String(k)).join(", ")}\n${USAGE}\n`);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main(): Promise<number> {
|
|
68
|
+
const a = parseArgs(process.argv.slice(2));
|
|
69
|
+
if (a.help || !a.subcommand) {
|
|
70
|
+
process.stderr.write(USAGE + "\n");
|
|
71
|
+
return a.help ? 0 : 2;
|
|
72
|
+
}
|
|
73
|
+
switch (a.subcommand) {
|
|
74
|
+
case "create": {
|
|
75
|
+
if (!need(a, ["instance", "snapshot", "branch"])) return 2;
|
|
76
|
+
const raw = await createSnapshot({
|
|
77
|
+
instance: a.instance!,
|
|
78
|
+
snapshot: a.snapshot!,
|
|
79
|
+
sourceBranch: a.branch!,
|
|
80
|
+
sourceBranchTime: a.atTime,
|
|
81
|
+
sourceBranchLsn: a.atLsn,
|
|
82
|
+
ttl: a.ttl,
|
|
83
|
+
noExpiry: a.noExpiry,
|
|
84
|
+
host: a.host,
|
|
85
|
+
});
|
|
86
|
+
out({ created: a.snapshot, raw: raw.trim() || undefined }, !!a.pretty);
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
case "delete": {
|
|
90
|
+
if (!need(a, ["instance", "snapshot"])) return 2;
|
|
91
|
+
await deleteSnapshot({ instance: a.instance!, snapshot: a.snapshot!, host: a.host });
|
|
92
|
+
out({ deleted: a.snapshot }, !!a.pretty);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
case "list": {
|
|
96
|
+
if (!need(a, ["instance"])) return 2;
|
|
97
|
+
const raw = await listSnapshots({ instance: a.instance!, host: a.host });
|
|
98
|
+
try {
|
|
99
|
+
out(JSON.parse(raw), !!a.pretty);
|
|
100
|
+
} catch {
|
|
101
|
+
process.stdout.write(raw);
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
process.stderr.write(`unknown subcommand '${a.subcommand}'\n${USAGE}\n`);
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
main().then(
|
|
112
|
+
(code) => process.exit(code),
|
|
113
|
+
(err) => {
|
|
114
|
+
process.stderr.write(String(err instanceof Error ? err.message : err) + "\n");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Lakebase SNAPSHOT primitives — the fast verify-branch substrate.
|
|
2
|
+
//
|
|
3
|
+
// A snapshot is an immutable point-in-time copy of a branch's data. Capture the
|
|
4
|
+
// committed schema ONCE (create), then branch copy-on-write from it per verify
|
|
5
|
+
// cycle (createBranch({ fromSnapshot })) instead of forking + `alembic upgrade
|
|
6
|
+
// head` every time. Because the snapshot is a fixed point, the per-cycle branch
|
|
7
|
+
// never goes stale. "Restore" is not an in-place op in Lakebase — you restore by
|
|
8
|
+
// branching from the snapshot (see createBranch's fromSnapshot path).
|
|
9
|
+
//
|
|
10
|
+
// API (verified): POST .../snapshots?snapshot_id=<id> with spec.source_branch
|
|
11
|
+
// (+ optional source_branch_time | source_branch_lsn for PITR, + one of
|
|
12
|
+
// no_expiry | ttl | expire_time). Mirrors the one-databricks-CLI-wrapper rule.
|
|
13
|
+
import { runDatabricks } from "./databricks-cli.js";
|
|
14
|
+
import { projectPath } from "./branch-utils.js";
|
|
15
|
+
import { KIT_TIMEOUTS } from "./kit-config.js";
|
|
16
|
+
|
|
17
|
+
/** Full branch resource path from a bare leaf or an already-full path. */
|
|
18
|
+
function branchResource(instance: string, branch: string): string {
|
|
19
|
+
return branch.includes("/") ? branch : `${projectPath(instance)}/branches/${branch}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Full snapshot resource path from a bare id or an already-full path. */
|
|
23
|
+
function snapshotResource(instance: string, snapshot: string): string {
|
|
24
|
+
return snapshot.includes("/") ? snapshot : `${projectPath(instance)}/snapshots/${snapshot}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SnapshotSpec {
|
|
28
|
+
source_branch: string;
|
|
29
|
+
source_branch_time?: string;
|
|
30
|
+
source_branch_lsn?: string;
|
|
31
|
+
no_expiry?: boolean;
|
|
32
|
+
ttl?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CreateSnapshotArgs {
|
|
36
|
+
instance: string;
|
|
37
|
+
/** Client-chosen snapshot id (becomes the resource leaf). */
|
|
38
|
+
snapshot: string;
|
|
39
|
+
/** Branch to snapshot — bare leaf (e.g. "staging") or full path. */
|
|
40
|
+
sourceBranch: string;
|
|
41
|
+
/** PITR: snapshot the branch AS OF this ISO timestamp (source_branch_time). */
|
|
42
|
+
sourceBranchTime?: string;
|
|
43
|
+
/** PITR: snapshot AS OF this LSN (source_branch_lsn). Exclusive with sourceBranchTime. */
|
|
44
|
+
sourceBranchLsn?: string;
|
|
45
|
+
/** TTL string ("604800s"). Mutually exclusive with noExpiry. */
|
|
46
|
+
ttl?: string;
|
|
47
|
+
/** Never auto-expire. Default true when ttl is unset (the API requires one). */
|
|
48
|
+
noExpiry?: boolean;
|
|
49
|
+
host?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build the create-snapshot `spec` (pure — unit-testable without the CLI). */
|
|
53
|
+
export function buildSnapshotSpec(args: CreateSnapshotArgs): SnapshotSpec {
|
|
54
|
+
if (args.sourceBranchTime && args.sourceBranchLsn) {
|
|
55
|
+
throw new Error("sourceBranchTime and sourceBranchLsn are mutually exclusive — pass at most one point-in-time.");
|
|
56
|
+
}
|
|
57
|
+
if (args.ttl && args.noExpiry === true) {
|
|
58
|
+
throw new Error(`Cannot set both ttl ("${args.ttl}") and noExpiry: true — they are mutually exclusive.`);
|
|
59
|
+
}
|
|
60
|
+
const spec: SnapshotSpec = { source_branch: branchResource(args.instance, args.sourceBranch) };
|
|
61
|
+
if (args.sourceBranchTime) spec.source_branch_time = args.sourceBranchTime;
|
|
62
|
+
else if (args.sourceBranchLsn) spec.source_branch_lsn = args.sourceBranchLsn;
|
|
63
|
+
if (args.ttl) spec.ttl = args.ttl;
|
|
64
|
+
else if (args.noExpiry ?? true) spec.no_expiry = true;
|
|
65
|
+
return spec;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Create an immutable snapshot of a branch (waits for the operation to complete). */
|
|
69
|
+
export async function createSnapshot(args: CreateSnapshotArgs): Promise<string> {
|
|
70
|
+
const spec = buildSnapshotSpec(args);
|
|
71
|
+
return runDatabricks(
|
|
72
|
+
["postgres", "create-snapshot", projectPath(args.instance), args.snapshot, "--json", JSON.stringify({ spec })],
|
|
73
|
+
{ host: args.host, timeout: KIT_TIMEOUTS.cliCreateBranch },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deleteSnapshot(args: { instance: string; snapshot: string; host?: string }): Promise<string> {
|
|
78
|
+
return runDatabricks(["postgres", "delete-snapshot", snapshotResource(args.instance, args.snapshot)], {
|
|
79
|
+
host: args.host,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function listSnapshots(args: { instance: string; host?: string }): Promise<string> {
|
|
84
|
+
return runDatabricks(["postgres", "list-snapshots", projectPath(args.instance), "-o", "json"], { host: args.host });
|
|
85
|
+
}
|
|
@@ -155,6 +155,27 @@ else
|
|
|
155
155
|
exit 1
|
|
156
156
|
fi
|
|
157
157
|
|
|
158
|
+
# NOTE: a per-cycle client AC-scope (vitest -t / playwright --grep from SFTDD_TEST_IDS) was tried and
|
|
159
|
+
# REVERTED (live-proven harmful, 2026-10-24): scoping the per-cycle client pass to the current AC's
|
|
160
|
+
# @T specs stops the FULL client suite from running per cycle, which is what detects SUPERSESSION — a
|
|
161
|
+
# prior test asserting an element the current story removed (e.g. S2 split the combined
|
|
162
|
+
# `tracking-not-tracked` cell into batch/serial). Without that in-cycle RED the driver never refactors
|
|
163
|
+
# the superseded test, so it surfaces LATER as a deploy-verify HALT instead of a smooth in-cycle
|
|
164
|
+
# refactor. The client Vitest is cheap (jsdom) and the expensive Playwright E2E is already cost-gated
|
|
165
|
+
# by cycle layer below, so the full per-cycle client run stays — it is load-bearing, not waste.
|
|
166
|
+
#
|
|
167
|
+
# EXCEPTION — #2 parallel-lane scope (gated): in a SCOPED-LANE drive (CONSORT_SCOPE_AC set, i.e. a
|
|
168
|
+
# parallel per-AC lane / the contract story's serial-first AC), the cross-story supersession is owned by
|
|
169
|
+
# the DETERMINISTIC hoist sweep, NOT by this reactive full-client-run. So there the client pass is scoped
|
|
170
|
+
# to THIS AC's @T specs (it must not re-run the cross-story suite + re-trigger supersession wave-by-wave
|
|
171
|
+
# on the lanes' critical path). FLEET drives (no CONSORT_SCOPE_AC) are UNCHANGED — full client suite,
|
|
172
|
+
# supersession detection intact. The gate is what makes the reverted scope safe to re-introduce here.
|
|
173
|
+
SFTDD_CLIENT_GREP=""
|
|
174
|
+
if [ -n "${CONSORT_SCOPE_AC:-}" ] && [ -n "${SFTDD_TEST_IDS:-}" ]; then
|
|
175
|
+
SFTDD_CLIENT_GREP="@($(echo "$SFTDD_TEST_IDS" | tr -s ' ' '|' | sed 's/^|//;s/|$//'))"
|
|
176
|
+
echo "Scoped-lane client pass (CONSORT_SCOPE_AC=$CONSORT_SCOPE_AC): client tests scoped to $SFTDD_CLIENT_GREP"
|
|
177
|
+
fi
|
|
178
|
+
|
|
158
179
|
# React SPA client unit tests (Vitest + Testing Library). Only on a full run
|
|
159
180
|
# (no positional path arg, so a per-cycle backend-layer invocation does not drag
|
|
160
181
|
# in the client suite), and only when a client/ workspace is present. This is the
|
|
@@ -192,7 +213,11 @@ if [ "$#" -eq 0 ] && [ -f "$REPO_ROOT/client/package.json" ]; then
|
|
|
192
213
|
echo "Running client strict typecheck (tsc --noEmit, matching CI's build)..."
|
|
193
214
|
( cd "$REPO_ROOT/client" && npm run typecheck --if-present )
|
|
194
215
|
echo "Running client unit tests (Vitest)..."
|
|
195
|
-
|
|
216
|
+
if [ -n "$SFTDD_CLIENT_GREP" ]; then
|
|
217
|
+
( cd "$REPO_ROOT/client" && npm test -- -t "$SFTDD_CLIENT_GREP" )
|
|
218
|
+
else
|
|
219
|
+
( cd "$REPO_ROOT/client" && npm test )
|
|
220
|
+
fi
|
|
196
221
|
fi
|
|
197
222
|
|
|
198
223
|
# React SPA client E2E (Playwright). The client's end-to-end suite (client/tests/e2e/*.spec.ts) MUST run
|
|
@@ -273,7 +298,23 @@ if [ "$#" -eq 0 ] && [ -d "$REPO_ROOT/client/tests/e2e" ] \
|
|
|
273
298
|
# (old `!CI` and new `!!BASE_URL` alike) so the verify never reuses a stale
|
|
274
299
|
# server bound to an unmigrated branch (issue #197's false-500 misdiagnosis).
|
|
275
300
|
ensure_local_e2e_ports
|
|
276
|
-
|
|
301
|
+
e2e_rc=0
|
|
302
|
+
# #2 scoped-lane: scope the E2E to THIS AC's @T specs (gated on CONSORT_SCOPE_AC; fleet runs full).
|
|
303
|
+
if [ -n "$SFTDD_CLIENT_GREP" ]; then
|
|
304
|
+
( cd "$REPO_ROOT/client" && ./node_modules/.bin/playwright install chromium && CI=1 npm run test:e2e -- --grep "$SFTDD_CLIENT_GREP" ) || e2e_rc=$?
|
|
305
|
+
else
|
|
306
|
+
( cd "$REPO_ROOT/client" && ./node_modules/.bin/playwright install chromium && CI=1 npm run test:e2e ) || e2e_rc=$?
|
|
307
|
+
fi
|
|
308
|
+
# #7 within-cycle reap: Playwright's webServer SHOULD self-tear-down, but a killed/detached run
|
|
309
|
+
# leaves a uvicorn (+ vite) on the E2E ports that otherwise lingers until the NEXT cycle's
|
|
310
|
+
# scan-reaper — so a heavy-E2E cycle's repeated runs pile up. Reap them here, right after the run.
|
|
311
|
+
# NEVER when BASE_URL is set (the deploy gate owns that server, same guard as ensure_local_e2e_ports).
|
|
312
|
+
if [ -z "${BASE_URL:-}" ] && command -v lsof >/dev/null 2>&1; then
|
|
313
|
+
for p in "${E2E_BACKEND_PORT:-}" "${E2E_CLIENT_PORT:-}"; do
|
|
314
|
+
[ -n "$p" ] && lsof -ti tcp:"$p" 2>/dev/null | xargs kill 2>/dev/null || true
|
|
315
|
+
done
|
|
316
|
+
fi
|
|
317
|
+
[ "$e2e_rc" -eq 0 ] || exit "$e2e_rc"
|
|
277
318
|
fi
|
|
278
319
|
fi
|
|
279
320
|
|
|
@@ -25,6 +25,7 @@ import os
|
|
|
25
25
|
import subprocess
|
|
26
26
|
import tempfile
|
|
27
27
|
import time
|
|
28
|
+
import urllib.error
|
|
28
29
|
import urllib.request
|
|
29
30
|
|
|
30
31
|
import pytest
|
|
@@ -41,7 +42,10 @@ def _is_up(url: str) -> bool:
|
|
|
41
42
|
return resp.status < 500
|
|
42
43
|
except urllib.error.HTTPError as exc: # noqa: PERF203 - a 4xx still means it's up
|
|
43
44
|
return exc.code < 500
|
|
44
|
-
except
|
|
45
|
+
except (urllib.error.URLError, OSError):
|
|
46
|
+
# NOT a broad-integrity swallow: a readiness poll returns False on the server
|
|
47
|
+
# not-yet-listening (connection refused / DNS / socket timeout — all URLError/OSError),
|
|
48
|
+
# never masks a DB-integrity violation. Narrow so the smell gate does not false-positive.
|
|
45
49
|
return False
|
|
46
50
|
|
|
47
51
|
|
|
@@ -34,7 +34,7 @@ def db_session():
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
@pytest.fixture(autouse=True)
|
|
37
|
-
def _restore_migration_head_after_each_test():
|
|
37
|
+
def _restore_migration_head_after_each_test(request):
|
|
38
38
|
"""Within-run test isolation for migration up/down fixtures.
|
|
39
39
|
|
|
40
40
|
Some tests drive Alembic up/down against the shared verify database (a
|
|
@@ -42,10 +42,18 @@ def _restore_migration_head_after_each_test():
|
|
|
42
42
|
`upgrade head`). Run sequentially against ONE database, those fixtures can
|
|
43
43
|
leave it half-migrated , or, if a downgrade overshoots, below a table a later
|
|
44
44
|
test needs , so the next test fails with `relation ... does not exist`. This
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
teardown brings the DB back to `head` after a schema-mutating test.
|
|
46
|
+
|
|
47
|
+
SCOPED to ``@pytest.mark.migration`` tests only (perf): ONLY a migration test
|
|
48
|
+
downgrades/alters the schema , and the ``migration-marker-presence`` smell
|
|
49
|
+
GUARANTEES every schema-mutating test carries that marker , so a NON-migration
|
|
50
|
+
test cannot have changed the schema and needs no restore. Restoring after every
|
|
51
|
+
test ran a full alembic env-load + DB round-trip (~1-2s) after each of the many
|
|
52
|
+
non-migration tests to 'restore' a schema they never touched , the dominant cost
|
|
53
|
+
of the non-migration pass. Skipping it there is safe by construction."""
|
|
48
54
|
yield
|
|
55
|
+
if request.node.get_closest_marker("migration") is None:
|
|
56
|
+
return # a non-migration test cannot have altered the schema (marker smell enforces it)
|
|
49
57
|
try:
|
|
50
58
|
from pathlib import Path
|
|
51
59
|
|
|
@@ -251,6 +259,54 @@ def _consort_capture_tables(test_id, item):
|
|
|
251
259
|
pass
|
|
252
260
|
|
|
253
261
|
|
|
262
|
+
def pytest_collection_modifyitems(config, items):
|
|
263
|
+
"""Build-lane SCOPE (per-cycle waste elimination): when ``SFTDD_TEST_IDS`` is set (a space/comma
|
|
264
|
+
list of test ids), run ONLY the tests whose ``@pytest.mark.testid`` is in that set and DESELECT the
|
|
265
|
+
rest. The build's per-cycle GREEN verify sets it to the CURRENT STORY's ids, so a cycle re-runs
|
|
266
|
+
only its own story instead of every feature's suite (intra-story regressions still caught); the
|
|
267
|
+
story-close REFACTOR + feature-deploy gates leave it UNSET and run the FULL cross-story suite (that
|
|
268
|
+
is where a cross-story regression is caught, before merge). UNTAGGED items (no testid — shared
|
|
269
|
+
fixtures / infra) are always KEPT, never deselected. Unset/empty => no-op (full collection)."""
|
|
270
|
+
import os
|
|
271
|
+
import re
|
|
272
|
+
|
|
273
|
+
# DEFER superseded cross-story tests off the serial-first AC's verify (SFTDD_DEFER_FILES, set
|
|
274
|
+
# by the deferred serial-first AC of a parallel-lane fan-out). These prior-story tests fail only
|
|
275
|
+
# because THIS AC's drop migration superseded them; the coordinator's supersession sweep retargets
|
|
276
|
+
# them ONCE right after this AC, and the final feature-deploy verify (no defer) re-runs them
|
|
277
|
+
# retargeted. Deselected by FILE because the migration pass runs them regardless of the testid
|
|
278
|
+
# scope below, and they are typically cross-story / untagged (so always-KEPT by that scope).
|
|
279
|
+
defer_raw = (os.environ.get("SFTDD_DEFER_FILES") or "").strip()
|
|
280
|
+
if defer_raw:
|
|
281
|
+
defer_files = {f for f in re.split(r"[,\s]+", defer_raw) if f.endswith(".py")}
|
|
282
|
+
if defer_files:
|
|
283
|
+
kept, dropped = [], []
|
|
284
|
+
for item in items:
|
|
285
|
+
nodefile = item.nodeid.split("::", 1)[0].replace(os.sep, "/")
|
|
286
|
+
fspath = str(getattr(item, "fspath", "") or "").replace(os.sep, "/")
|
|
287
|
+
if any(nodefile == df or nodefile.endswith("/" + df) or fspath.endswith("/" + df) for df in defer_files):
|
|
288
|
+
dropped.append(item)
|
|
289
|
+
else:
|
|
290
|
+
kept.append(item)
|
|
291
|
+
if dropped:
|
|
292
|
+
config.hook.pytest_deselected(items=dropped)
|
|
293
|
+
items[:] = kept
|
|
294
|
+
|
|
295
|
+
raw = (os.environ.get("SFTDD_TEST_IDS") or "").strip()
|
|
296
|
+
if not raw:
|
|
297
|
+
return
|
|
298
|
+
wanted = {t for t in re.split(r"[,\s]+", raw) if t}
|
|
299
|
+
if not wanted:
|
|
300
|
+
return
|
|
301
|
+
selected, deselected = [], []
|
|
302
|
+
for item in items:
|
|
303
|
+
tid = _consort_testid(item)
|
|
304
|
+
(selected if (tid is None or tid in wanted) else deselected).append(item)
|
|
305
|
+
if deselected:
|
|
306
|
+
config.hook.pytest_deselected(items=deselected)
|
|
307
|
+
items[:] = selected
|
|
308
|
+
|
|
309
|
+
|
|
254
310
|
def pytest_runtest_setup(item):
|
|
255
311
|
"""Emit ``running`` the moment a tagged test starts, so the dashboard shows it in-flight."""
|
|
256
312
|
test_id = _consort_testid(item)
|
|
Binary file
|