@sdods/db 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +17 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/col.d.ts +32 -0
  4. package/dist/col.js +58 -0
  5. package/dist/create-db.d.ts +14 -0
  6. package/dist/create-db.js +58 -0
  7. package/dist/driver.d.ts +10 -0
  8. package/dist/driver.js +16 -0
  9. package/dist/ids.d.ts +2 -0
  10. package/dist/ids.js +2 -0
  11. package/dist/index.d.ts +17 -0
  12. package/dist/index.js +22 -0
  13. package/dist/ingest/attachments.d.ts +40 -0
  14. package/dist/ingest/attachments.js +154 -0
  15. package/dist/ingest/cucumber-ingest.d.ts +40 -0
  16. package/dist/ingest/cucumber-ingest.js +543 -0
  17. package/dist/ingest/finalize.d.ts +28 -0
  18. package/dist/ingest/finalize.js +132 -0
  19. package/dist/ingest/index.d.ts +26 -0
  20. package/dist/ingest/index.js +193 -0
  21. package/dist/ingest/message-index.d.ts +64 -0
  22. package/dist/ingest/message-index.js +146 -0
  23. package/dist/ingest/parse-ndjson.d.ts +10 -0
  24. package/dist/ingest/parse-ndjson.js +35 -0
  25. package/dist/ingest/pw-json-ingest.d.ts +14 -0
  26. package/dist/ingest/pw-json-ingest.js +259 -0
  27. package/dist/ingest/runner-json-ingest.d.ts +14 -0
  28. package/dist/ingest/runner-json-ingest.js +259 -0
  29. package/dist/ingest/types.d.ts +62 -0
  30. package/dist/ingest/types.js +89 -0
  31. package/dist/insights.d.ts +62 -0
  32. package/dist/insights.js +184 -0
  33. package/dist/migrate.d.ts +30 -0
  34. package/dist/migrate.js +114 -0
  35. package/dist/migrations/0001_platform_init.d.ts +5 -0
  36. package/dist/migrations/0001_platform_init.js +48 -0
  37. package/dist/migrations/0002_results.d.ts +5 -0
  38. package/dist/migrations/0002_results.js +124 -0
  39. package/dist/migrations/0003_improvement.d.ts +5 -0
  40. package/dist/migrations/0003_improvement.js +56 -0
  41. package/dist/migrations/0004_test_data.d.ts +5 -0
  42. package/dist/migrations/0004_test_data.js +48 -0
  43. package/dist/migrations/0005_integrations_agents_audit.d.ts +5 -0
  44. package/dist/migrations/0005_integrations_agents_audit.js +100 -0
  45. package/dist/migrations/0006_schedules.d.ts +5 -0
  46. package/dist/migrations/0006_schedules.js +34 -0
  47. package/dist/migrations/0007_hierarchy.d.ts +5 -0
  48. package/dist/migrations/0007_hierarchy.js +76 -0
  49. package/dist/migrations/0008_runner_naming.d.ts +14 -0
  50. package/dist/migrations/0008_runner_naming.js +35 -0
  51. package/dist/migrations/helpers.d.ts +15 -0
  52. package/dist/migrations/helpers.js +30 -0
  53. package/dist/migrations/index.d.ts +6 -0
  54. package/dist/migrations/index.js +22 -0
  55. package/dist/repos/auth.d.ts +109 -0
  56. package/dist/repos/auth.js +258 -0
  57. package/dist/repos/hierarchy.d.ts +151 -0
  58. package/dist/repos/hierarchy.js +465 -0
  59. package/dist/repos/improvement.d.ts +84 -0
  60. package/dist/repos/improvement.js +242 -0
  61. package/dist/repos/index.d.ts +8 -0
  62. package/dist/repos/index.js +8 -0
  63. package/dist/repos/platform.d.ts +250 -0
  64. package/dist/repos/platform.js +429 -0
  65. package/dist/repos/projects.d.ts +87 -0
  66. package/dist/repos/projects.js +118 -0
  67. package/dist/repos/runs.d.ts +306 -0
  68. package/dist/repos/runs.js +321 -0
  69. package/dist/repos/test-data.d.ts +84 -0
  70. package/dist/repos/test-data.js +291 -0
  71. package/dist/schema.d.ts +514 -0
  72. package/dist/schema.js +37 -0
  73. package/dist/scopes.d.ts +2 -0
  74. package/dist/scopes.js +2 -0
  75. package/dist/switch/export.d.ts +15 -0
  76. package/dist/switch/export.js +67 -0
  77. package/dist/switch/import.d.ts +16 -0
  78. package/dist/switch/import.js +106 -0
  79. package/dist/switch/index.d.ts +5 -0
  80. package/dist/switch/index.js +5 -0
  81. package/dist/switch/prune.d.ts +19 -0
  82. package/dist/switch/prune.js +50 -0
  83. package/dist/switch/switch.d.ts +34 -0
  84. package/dist/switch/switch.js +133 -0
  85. package/dist/test-data.d.ts +22 -0
  86. package/dist/test-data.js +79 -0
  87. package/package.json +50 -0
@@ -0,0 +1,89 @@
1
+ /** cucumber Timestamp/Duration → ms */
2
+ export function tsToMs(t) {
3
+ if (!t)
4
+ return null;
5
+ return Number(t.seconds) * 1000 + Math.round((t.nanos ?? 0) / 1e6);
6
+ }
7
+ export function tsToIso(t) {
8
+ const ms = tsToMs(t);
9
+ return ms === null ? null : new Date(ms).toISOString();
10
+ }
11
+ export function cucumberStatus(s) {
12
+ switch ((s ?? '').toUpperCase()) {
13
+ case 'PASSED':
14
+ return 'passed';
15
+ case 'FAILED':
16
+ return 'failed';
17
+ case 'SKIPPED':
18
+ case 'PENDING':
19
+ case 'UNDEFINED':
20
+ case 'AMBIGUOUS':
21
+ return 'skipped';
22
+ default:
23
+ return 'unknown';
24
+ }
25
+ }
26
+ export function runnerStatus(s) {
27
+ switch (s) {
28
+ case 'passed':
29
+ return 'passed';
30
+ case 'failed':
31
+ return 'failed';
32
+ case 'timedOut':
33
+ return 'timedOut';
34
+ case 'skipped':
35
+ return 'skipped';
36
+ case 'interrupted':
37
+ return 'interrupted';
38
+ default:
39
+ return 'unknown';
40
+ }
41
+ }
42
+ /** Module = first directory after `features/`, else the first directory segment. */
43
+ export function moduleFromUri(uri) {
44
+ const segs = uri.replace(/\\/g, '/').split('/').filter(Boolean);
45
+ if (segs.length < 2)
46
+ return null;
47
+ const fi = segs.lastIndexOf('features');
48
+ if (fi >= 0 && fi < segs.length - 2)
49
+ return segs[fi + 1];
50
+ if (fi >= 0)
51
+ return null;
52
+ // non-feature files (recorded specs): the directory that holds the file
53
+ return segs[segs.length - 2];
54
+ }
55
+ /** Strip playwright-bdd's `[<project>]:` prefix from a feature uri. */
56
+ export function splitUri(uri) {
57
+ const m = /^\[([^\]]+)\]:(.*)$/.exec(uri);
58
+ return m ? { runnerProject: m[1], uri: m[2] } : { runnerProject: null, uri };
59
+ }
60
+ const LOCATOR_PATTERNS = [
61
+ /locator\.\w+: Timeout[\s\S]*?waiting for (locator\([^)]*\)|getBy\w+\([^)]*\)|[^\n]+)/i,
62
+ /waiting for (locator\([^\n]*?\)|getBy\w+\([^\n]*?\))/i,
63
+ /strict mode violation: (locator\([^\n]*?\)|getBy\w+\([^\n]*?\))/i,
64
+ /expect\((locator\([^\n]*?\)|getBy\w+\([^\n]*?\))\)/i,
65
+ ];
66
+ /** Extract the selector from a Playwright locator error message, if any. */
67
+ export function selectorFromError(message) {
68
+ if (!message)
69
+ return null;
70
+ for (const re of LOCATOR_PATTERNS) {
71
+ const m = re.exec(message);
72
+ if (m?.[1])
73
+ return m[1].trim().slice(0, 300);
74
+ }
75
+ return null;
76
+ }
77
+ export function emptyTotals() {
78
+ return {
79
+ total: 0,
80
+ passed: 0,
81
+ failed: 0,
82
+ skipped: 0,
83
+ timedOut: 0,
84
+ flaky: 0,
85
+ healed: 0,
86
+ durationMs: 0,
87
+ };
88
+ }
89
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,62 @@
1
+ import { type Kysely } from 'kysely';
2
+ import type { Database } from './schema.js';
3
+ export interface InsightsOptions {
4
+ projectSlug: string;
5
+ /** number of most recent runs to consider (default 30) */
6
+ window?: number;
7
+ /** thresholds */
8
+ flakyThreshold?: number;
9
+ fragilityThreshold?: number;
10
+ minRuns?: number;
11
+ }
12
+ export interface ScenarioInsight {
13
+ fingerprint: string;
14
+ runnerProject: string;
15
+ featureUri: string;
16
+ scenarioName: string;
17
+ module: string | null;
18
+ runs: number;
19
+ passed: number;
20
+ failed: number;
21
+ flaky: number;
22
+ /** (passed_on_retry + outcome flips) / runs */
23
+ flakinessScore: number;
24
+ p95DurationMs: number | null;
25
+ medianDurationMs: number | null;
26
+ instability: number | null;
27
+ quarantineCandidate: boolean;
28
+ }
29
+ export interface LocatorInsight {
30
+ selector: string;
31
+ pageHint: string | null;
32
+ fails: number;
33
+ heals: number;
34
+ uses: number;
35
+ fragility: number;
36
+ hot: boolean;
37
+ suggestedSelector: string | null;
38
+ }
39
+ export interface EnvInsight {
40
+ env: string;
41
+ runs: number;
42
+ envAttributedFailures: number;
43
+ stability: number;
44
+ }
45
+ export interface Insights {
46
+ projectSlug: string;
47
+ window: number;
48
+ runsConsidered: number;
49
+ passRate: number;
50
+ scenarios: ScenarioInsight[];
51
+ locators: LocatorInsight[];
52
+ envs: EnvInsight[];
53
+ suiteHealth: number;
54
+ suiteHealthTrend: number[];
55
+ computedAt: string;
56
+ }
57
+ /**
58
+ * Deterministic metrics over the last `window` runs of a project. Pure reads; results are
59
+ * returned (and written by the CLI into `flaky_stats` / `locator_stats` when asked).
60
+ */
61
+ export declare function computeInsights(db: Kysely<Database>, opts: InsightsOptions): Promise<Insights>;
62
+ //# sourceMappingURL=insights.d.ts.map
@@ -0,0 +1,184 @@
1
+ import { sql } from 'kysely';
2
+ import { readBool } from './col.js';
3
+ const ENV_ERROR = /(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|net::ERR_|502|503|504|Timeout .* exceeded|navigation timeout)/i;
4
+ /**
5
+ * Deterministic metrics over the last `window` runs of a project. Pure reads; results are
6
+ * returned (and written by the CLI into `flaky_stats` / `locator_stats` when asked).
7
+ */
8
+ export async function computeInsights(db, opts) {
9
+ const window = opts.window ?? 30;
10
+ const flakyThreshold = opts.flakyThreshold ?? 0.2;
11
+ const fragilityThreshold = opts.fragilityThreshold ?? 0.1;
12
+ const minRuns = opts.minRuns ?? 10;
13
+ const project = await db
14
+ .selectFrom('projects')
15
+ .select(['id'])
16
+ .where('slug', '=', opts.projectSlug)
17
+ .executeTakeFirst();
18
+ if (!project)
19
+ throw new Error(`Unknown project ${opts.projectSlug}`);
20
+ const runs = await db
21
+ .selectFrom('runs')
22
+ .select(['id', 'env_name', 'status', 'totals_json', 'created_at', 'started_at'])
23
+ .where('project_id', '=', project.id)
24
+ .where('status', 'in', ['passed', 'failed'])
25
+ .orderBy(sql `coalesce(started_at, created_at)`, 'desc')
26
+ .limit(window)
27
+ .execute();
28
+ const runIds = runs.map((r) => r.id);
29
+ const runOrder = new Map(runIds.map((id, i) => [id, i]));
30
+ const scenarios = runIds.length
31
+ ? await db
32
+ .selectFrom('scenarios')
33
+ .select([
34
+ 'run_id',
35
+ 'fingerprint',
36
+ 'runner_project',
37
+ 'feature_uri',
38
+ 'scenario_name',
39
+ 'module',
40
+ 'status',
41
+ 'flaky',
42
+ 'duration_ms',
43
+ 'error_message',
44
+ ])
45
+ .where('run_id', 'in', runIds)
46
+ .execute()
47
+ : [];
48
+ // ── scenario flakiness ────────────────────────────────────────────────
49
+ const groups = new Map();
50
+ for (const s of scenarios) {
51
+ const key = `${s.fingerprint}|${s.runner_project}`;
52
+ const list = groups.get(key) ?? [];
53
+ list.push(s);
54
+ groups.set(key, list);
55
+ }
56
+ const scenarioInsights = [];
57
+ for (const list of groups.values()) {
58
+ const ordered = [...list]
59
+ .sort((a, b) => (runOrder.get(a.run_id) ?? 0) - (runOrder.get(b.run_id) ?? 0))
60
+ .reverse(); // oldest → newest
61
+ const runsN = ordered.length;
62
+ const passed = ordered.filter((s) => s.status === 'passed' && !readBool(s.flaky)).length;
63
+ const failed = ordered.filter((s) => s.status === 'failed' || s.status === 'timedOut').length;
64
+ const flakyRetries = ordered.filter((s) => readBool(s.flaky)).length;
65
+ let flips = 0;
66
+ for (let i = 1; i < ordered.length; i++) {
67
+ const prev = ordered[i - 1].status === 'passed';
68
+ const cur = ordered[i].status === 'passed';
69
+ if (prev !== cur)
70
+ flips++;
71
+ }
72
+ const flakinessScore = runsN ? round((flakyRetries + flips) / runsN) : 0;
73
+ const durations = ordered
74
+ .map((s) => s.duration_ms)
75
+ .filter((d) => typeof d === 'number' && d > 0)
76
+ .sort((a, b) => a - b);
77
+ const median = durations.length ? durations[Math.floor(durations.length / 2)] : null;
78
+ const p95 = durations.length
79
+ ? durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))]
80
+ : null;
81
+ const last = ordered[ordered.length - 1];
82
+ scenarioInsights.push({
83
+ fingerprint: last.fingerprint,
84
+ runnerProject: last.runner_project,
85
+ featureUri: last.feature_uri,
86
+ scenarioName: last.scenario_name,
87
+ module: last.module ?? null,
88
+ runs: runsN,
89
+ passed,
90
+ failed,
91
+ flaky: flakyRetries,
92
+ flakinessScore,
93
+ p95DurationMs: p95,
94
+ medianDurationMs: median,
95
+ instability: median && p95 ? round(p95 / median) : null,
96
+ quarantineCandidate: runsN >= minRuns && flakinessScore >= flakyThreshold,
97
+ });
98
+ }
99
+ scenarioInsights.sort((a, b) => b.flakinessScore - a.flakinessScore || b.failed - a.failed);
100
+ // ── locator fragility ─────────────────────────────────────────────────
101
+ const locatorRows = await db
102
+ .selectFrom('locator_stats')
103
+ .selectAll()
104
+ .where('project_id', '=', project.id)
105
+ .execute();
106
+ const locators = locatorRows
107
+ .map((l) => {
108
+ const uses = Math.max(l.use_count, l.fail_count + l.heal_count, 1);
109
+ const fragility = round((l.fail_count + 0.5 * l.heal_count) / uses);
110
+ return {
111
+ selector: l.selector,
112
+ pageHint: l.page_hint,
113
+ fails: l.fail_count,
114
+ heals: l.heal_count,
115
+ uses: l.use_count,
116
+ fragility,
117
+ hot: fragility >= fragilityThreshold && l.heal_count >= 3,
118
+ suggestedSelector: l.suggested_selector,
119
+ };
120
+ })
121
+ .sort((a, b) => b.fragility - a.fragility);
122
+ // ── env stability ─────────────────────────────────────────────────────
123
+ const envMap = new Map();
124
+ for (const r of runs) {
125
+ const e = envMap.get(r.env_name) ?? { runs: 0, envFails: 0 };
126
+ e.runs++;
127
+ const failedHere = scenarios.filter((s) => s.run_id === r.id && (s.status === 'failed' || s.status === 'timedOut'));
128
+ const envLike = failedHere.filter((s) => s.error_message && ENV_ERROR.test(s.error_message));
129
+ const distinct = new Set(envLike.map((s) => s.fingerprint)).size;
130
+ if (distinct >= 3)
131
+ e.envFails++;
132
+ envMap.set(r.env_name, e);
133
+ }
134
+ const envs = [...envMap.entries()].map(([env, e]) => ({
135
+ env,
136
+ runs: e.runs,
137
+ envAttributedFailures: e.envFails,
138
+ stability: e.runs ? round(1 - e.envFails / e.runs) : 1,
139
+ }));
140
+ // ── suite health ──────────────────────────────────────────────────────
141
+ const perRunPass = runs.map((r) => {
142
+ const list = scenarios.filter((s) => s.run_id === r.id);
143
+ if (!list.length)
144
+ return null;
145
+ return list.filter((s) => s.status === 'passed').length / list.length;
146
+ });
147
+ const validPass = perRunPass.filter((p) => p !== null);
148
+ const passRate = validPass.length
149
+ ? round(validPass.reduce((a, b) => a + b, 0) / validPass.length)
150
+ : 0;
151
+ const meanFlaky = scenarioInsights.length
152
+ ? scenarioInsights.reduce((a, s) => a + s.flakinessScore, 0) / scenarioInsights.length
153
+ : 0;
154
+ const meanFragility = locators.length
155
+ ? locators.reduce((a, l) => a + l.fragility, 0) / locators.length
156
+ : 0;
157
+ const durationCompliance = 1; // duration budgets are evaluated by the runtime; placeholder keeps the formula stable
158
+ const suiteHealth = round(0.5 * passRate +
159
+ 0.2 * (1 - meanFlaky) +
160
+ 0.2 * (1 - Math.min(1, meanFragility)) +
161
+ 0.1 * durationCompliance);
162
+ const trend = [];
163
+ const chronological = [...validPass].reverse();
164
+ for (let i = 0; i < chronological.length; i++) {
165
+ const slice = chronological.slice(Math.max(0, i - 6), i + 1);
166
+ trend.push(round(slice.reduce((a, b) => a + b, 0) / slice.length));
167
+ }
168
+ return {
169
+ projectSlug: opts.projectSlug,
170
+ window,
171
+ runsConsidered: runs.length,
172
+ passRate,
173
+ scenarios: scenarioInsights,
174
+ locators,
175
+ envs,
176
+ suiteHealth,
177
+ suiteHealthTrend: trend,
178
+ computedAt: new Date().toISOString(),
179
+ };
180
+ }
181
+ function round(n) {
182
+ return Math.round(n * 10000) / 10000;
183
+ }
184
+ //# sourceMappingURL=insights.js.map
@@ -0,0 +1,30 @@
1
+ import { type Kysely } from 'kysely';
2
+ import { Migrator } from 'kysely/migration';
3
+ import { type Col } from './col.js';
4
+ import type { Driver } from './driver.js';
5
+ import type { SdodsDb } from './create-db.js';
6
+ export interface SdodsMigration {
7
+ up(db: Kysely<any>, c: Col, driver: Driver): Promise<void>;
8
+ down?(db: Kysely<any>, c: Col, driver: Driver): Promise<void>;
9
+ }
10
+ /**
11
+ * Move a pre-rename ledger into place before the migrator reads it. Safe to call on every
12
+ * start: it does nothing unless the old ledger exists and actually records migrations.
13
+ *
14
+ * A start that already failed this way leaves an empty ledger under the current name behind,
15
+ * so an empty one is dropped rather than treated as authoritative. An empty ledger records
16
+ * nothing, so nothing is lost; a non-empty one is left untouched and adoption is declined.
17
+ */
18
+ export declare function adoptLegacyLedger({ db }: SdodsDb): Promise<string[]>;
19
+ export declare function createMigrator({ db, driver }: SdodsDb): Migrator;
20
+ export declare function migrateToLatest(adb: SdodsDb): Promise<import("kysely/migration").MigrationResult[]>;
21
+ export declare function migrateDown(adb: SdodsDb): Promise<import("kysely/migration").MigrationResult[]>;
22
+ export declare function migrateTo(adb: SdodsDb, name: string): Promise<import("kysely/migration").MigrationResult[]>;
23
+ export declare function migrationStatus(adb: SdodsDb): Promise<{
24
+ name: string;
25
+ executedAt: string | null;
26
+ }[]>;
27
+ /** Drop every SDODS table (sqlite only, guarded by the CLI `--yes`). */
28
+ export declare function resetDatabase(adb: SdodsDb): Promise<void>;
29
+ export declare const MIGRATION_NAMES: string[];
30
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1,114 @@
1
+ import { sql } from 'kysely';
2
+ import { Migrator, } from 'kysely/migration';
3
+ import { col } from './col.js';
4
+ import { MIGRATIONS } from './migrations/index.js';
5
+ import { MIGRATION_LOCK_TABLE, MIGRATION_TABLE } from './schema.js';
6
+ /** Binds the driver into every migration so one file serves both dialects. */
7
+ class DriverBoundProvider {
8
+ driver;
9
+ constructor(driver) {
10
+ this.driver = driver;
11
+ }
12
+ async getMigrations() {
13
+ const c = col(this.driver);
14
+ const out = {};
15
+ for (const [name, m] of Object.entries(MIGRATIONS)) {
16
+ out[name] = {
17
+ up: (db) => m.up(db, c, this.driver),
18
+ down: m.down ? (db) => m.down(db, c, this.driver) : undefined,
19
+ };
20
+ }
21
+ return out;
22
+ }
23
+ }
24
+ /**
25
+ * Ledger tables the platform used before it was renamed. A database created under the old
26
+ * name is fully migrated, but the migrator cannot see its ledger, so it would replay every
27
+ * migration and fail on the first CREATE TABLE. Renaming the ledger adopts the database.
28
+ */
29
+ const LEGACY_MIGRATION_TABLE = 'automax_migrations';
30
+ const LEGACY_LOCK_TABLE = 'automax_migrations_lock';
31
+ async function tableExists(db, name) {
32
+ const tables = await db.introspection.getTables();
33
+ return tables.some((t) => t.name === name);
34
+ }
35
+ async function rowCount(db, name) {
36
+ const rows = await sql `select count(*) as n from ${sql.ref(name)}`.execute(db);
37
+ return Number(rows.rows[0]?.n ?? 0);
38
+ }
39
+ /**
40
+ * Move a pre-rename ledger into place before the migrator reads it. Safe to call on every
41
+ * start: it does nothing unless the old ledger exists and actually records migrations.
42
+ *
43
+ * A start that already failed this way leaves an empty ledger under the current name behind,
44
+ * so an empty one is dropped rather than treated as authoritative. An empty ledger records
45
+ * nothing, so nothing is lost; a non-empty one is left untouched and adoption is declined.
46
+ */
47
+ export async function adoptLegacyLedger({ db }) {
48
+ if (!(await tableExists(db, LEGACY_MIGRATION_TABLE)))
49
+ return [];
50
+ if ((await rowCount(db, LEGACY_MIGRATION_TABLE)) === 0)
51
+ return [];
52
+ if (await tableExists(db, MIGRATION_TABLE)) {
53
+ if ((await rowCount(db, MIGRATION_TABLE)) > 0)
54
+ return [];
55
+ await sql `drop table ${sql.ref(MIGRATION_TABLE)}`.execute(db);
56
+ }
57
+ await sql `alter table ${sql.ref(LEGACY_MIGRATION_TABLE)} rename to ${sql.ref(MIGRATION_TABLE)}`.execute(db);
58
+ const renamed = [`${LEGACY_MIGRATION_TABLE} -> ${MIGRATION_TABLE}`];
59
+ // The lock table holds no history, so whichever one is in place will do.
60
+ if (await tableExists(db, LEGACY_LOCK_TABLE)) {
61
+ if (await tableExists(db, MIGRATION_LOCK_TABLE)) {
62
+ await sql `drop table ${sql.ref(LEGACY_LOCK_TABLE)}`.execute(db);
63
+ }
64
+ else {
65
+ await sql `alter table ${sql.ref(LEGACY_LOCK_TABLE)} rename to ${sql.ref(MIGRATION_LOCK_TABLE)}`.execute(db);
66
+ renamed.push(`${LEGACY_LOCK_TABLE} -> ${MIGRATION_LOCK_TABLE}`);
67
+ }
68
+ }
69
+ return renamed;
70
+ }
71
+ export function createMigrator({ db, driver }) {
72
+ return new Migrator({
73
+ db,
74
+ provider: new DriverBoundProvider(driver),
75
+ migrationTableName: MIGRATION_TABLE,
76
+ migrationLockTableName: MIGRATION_LOCK_TABLE,
77
+ });
78
+ }
79
+ function assertOk(result) {
80
+ if (result.error)
81
+ throw result.error instanceof Error ? result.error : new Error(String(result.error));
82
+ return result.results ?? [];
83
+ }
84
+ export async function migrateToLatest(adb) {
85
+ await adoptLegacyLedger(adb);
86
+ return assertOk(await createMigrator(adb).migrateToLatest());
87
+ }
88
+ export async function migrateDown(adb) {
89
+ await adoptLegacyLedger(adb);
90
+ return assertOk(await createMigrator(adb).migrateDown());
91
+ }
92
+ export async function migrateTo(adb, name) {
93
+ await adoptLegacyLedger(adb);
94
+ return assertOk(await createMigrator(adb).migrateTo(name));
95
+ }
96
+ export async function migrationStatus(adb) {
97
+ await adoptLegacyLedger(adb);
98
+ const list = await createMigrator(adb).getMigrations();
99
+ return list.map((m) => ({
100
+ name: m.name,
101
+ executedAt: m.executedAt ? m.executedAt.toISOString() : null,
102
+ }));
103
+ }
104
+ /** Drop every SDODS table (sqlite only, guarded by the CLI `--yes`). */
105
+ export async function resetDatabase(adb) {
106
+ if (adb.driver !== 'sqlite')
107
+ throw new Error('reset is only supported on sqlite; drop the Postgres database yourself.');
108
+ const migrator = createMigrator(adb);
109
+ const applied = (await migrator.getMigrations()).filter((m) => m.executedAt);
110
+ for (let i = 0; i < applied.length; i++)
111
+ assertOk(await migrator.migrateDown());
112
+ }
113
+ export const MIGRATION_NAMES = Object.keys(MIGRATIONS);
114
+ //# sourceMappingURL=migrate.js.map
@@ -0,0 +1,5 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Col } from '../col.js';
3
+ export declare function up(db: Kysely<any>, c: Col): Promise<void>;
4
+ export declare function down(db: Kysely<any>): Promise<void>;
5
+ //# sourceMappingURL=0001_platform_init.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { dropTables, fk, index, stamps, table } from './helpers.js';
2
+ export async function up(db, c) {
3
+ await stamps(table(db, 'roles', c)
4
+ .addColumn('name', 'text', (cb) => cb.notNull().unique())
5
+ .addColumn('permissions_json', c.json, (cb) => cb.notNull()), c, false).execute();
6
+ await stamps(fk(table(db, 'users', c)
7
+ .addColumn('username', 'text', (cb) => cb.notNull().unique())
8
+ .addColumn('email', 'text')
9
+ .addColumn('password_hash', 'text', (cb) => cb.notNull()), 'role_id', 'roles', c, { onDelete: 'restrict' })
10
+ .addColumn('active', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? true : 1))
11
+ .addColumn('auth_provider', 'text', (cb) => cb.notNull().defaultTo('local'))
12
+ .addColumn('external_id', 'text')
13
+ .addColumn('last_login_at', c.ts), c).execute();
14
+ await fk(table(db, 'sessions', c), 'user_id', 'users', c)
15
+ .addColumn('csrf_token', 'text', (cb) => cb.notNull())
16
+ .addColumn('ip', 'text')
17
+ .addColumn('user_agent', 'text')
18
+ .addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
19
+ .addColumn('expires_at', c.ts, (cb) => cb.notNull())
20
+ .addColumn('last_seen_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
21
+ .execute();
22
+ await index(db, 'idx_sessions_user', 'sessions', ['user_id']);
23
+ await index(db, 'idx_sessions_expires', 'sessions', ['expires_at']);
24
+ await stamps(table(db, 'projects', c)
25
+ .addColumn('slug', 'text', (cb) => cb.notNull().unique())
26
+ .addColumn('name', 'text', (cb) => cb.notNull())
27
+ .addColumn('description', 'text')
28
+ .addColumn('root_path', 'text')
29
+ .addColumn('config_json', c.json, (cb) => cb.notNull())
30
+ .addColumn('config_hash', 'text')
31
+ .addColumn('layers_json', c.json, (cb) => cb.notNull())
32
+ .addColumn('browsers_json', c.json, (cb) => cb.notNull())
33
+ .addColumn('tags_policy_json', c.json, (cb) => cb.notNull())
34
+ .addColumn('screenshot_policy_json', c.json, (cb) => cb.notNull())
35
+ .addColumn('archived', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0)), c).execute();
36
+ await stamps(fk(table(db, 'environments', c), 'project_id', 'projects', c)
37
+ .addColumn('name', 'text', (cb) => cb.notNull())
38
+ .addColumn('base_url', 'text')
39
+ .addColumn('api_base_url', 'text')
40
+ .addColumn('config_json', c.json, (cb) => cb.notNull())
41
+ .addColumn('secret_keys_json', c.json, (cb) => cb.notNull())
42
+ .addColumn('is_default', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0)), c).execute();
43
+ await index(db, 'uq_environments_project_name', 'environments', ['project_id', 'name'], true);
44
+ }
45
+ export async function down(db) {
46
+ await dropTables(db, ['environments', 'projects', 'sessions', 'users', 'roles']);
47
+ }
48
+ //# sourceMappingURL=0001_platform_init.js.map
@@ -0,0 +1,5 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Col } from '../col.js';
3
+ export declare function up(db: Kysely<any>, c: Col): Promise<void>;
4
+ export declare function down(db: Kysely<any>): Promise<void>;
5
+ //# sourceMappingURL=0002_results.d.ts.map
@@ -0,0 +1,124 @@
1
+ import { dropTables, fk, index, stamps, table } from './helpers.js';
2
+ export async function up(db, c) {
3
+ await stamps(fk(fk(table(db, 'runs', c), 'project_id', 'projects', c), 'environment_id', 'environments', c, {
4
+ nullable: true,
5
+ onDelete: 'set null',
6
+ })
7
+ .addColumn('env_name', 'text', (cb) => cb.notNull())
8
+ .addColumn('trigger', 'text', (cb) => cb.notNull().defaultTo('cli'))
9
+ .addColumn('status', 'text', (cb) => cb.notNull().defaultTo('queued'))
10
+ .addColumn('suite_tag', 'text')
11
+ .addColumn('tags_expr', 'text')
12
+ .addColumn('layers_json', c.json, (cb) => cb.notNull())
13
+ .addColumn('browsers_json', c.json, (cb) => cb.notNull())
14
+ .addColumn('shard_total', c.int)
15
+ .addColumn('shards_ingested', c.int, (cb) => cb.notNull().defaultTo(0))
16
+ .addColumn('git_sha', 'text')
17
+ .addColumn('git_branch', 'text')
18
+ .addColumn('ci_provider', 'text')
19
+ .addColumn('ci_run_id', 'text')
20
+ .addColumn('ci_url', 'text')
21
+ .addColumn('started_by', 'text')
22
+ .addColumn('command', 'text')
23
+ .addColumn('started_at', c.ts)
24
+ .addColumn('finished_at', c.ts)
25
+ .addColumn('duration_ms', c.int)
26
+ .addColumn('totals_json', c.json, (cb) => cb.notNull())
27
+ .addColumn('artifacts_dir', 'text')
28
+ .addColumn('pw_report_rel', 'text')
29
+ .addColumn('exit_code', c.int)
30
+ .addColumn('error_text', 'text'), c).execute();
31
+ await index(db, 'idx_runs_project_started', 'runs', ['project_id', 'started_at']);
32
+ await index(db, 'idx_runs_status', 'runs', ['status']);
33
+ await index(db, 'idx_runs_ci_run', 'runs', ['ci_run_id']);
34
+ await stamps(fk(fk(table(db, 'scenarios', c), 'run_id', 'runs', c), 'project_id', 'projects', c)
35
+ .addColumn('natural_key', 'text', (cb) => cb.notNull())
36
+ .addColumn('fingerprint', 'text', (cb) => cb.notNull())
37
+ .addColumn('source', 'text', (cb) => cb.notNull().defaultTo('gherkin'))
38
+ .addColumn('feature_uri', 'text', (cb) => cb.notNull())
39
+ .addColumn('feature_name', 'text')
40
+ .addColumn('scenario_name', 'text', (cb) => cb.notNull())
41
+ .addColumn('examples_row', c.int)
42
+ .addColumn('pw_project', 'text', (cb) => cb.notNull())
43
+ .addColumn('layer', 'text', (cb) => cb.notNull())
44
+ .addColumn('browser', 'text')
45
+ .addColumn('suite_tag', 'text')
46
+ .addColumn('tags_json', c.json, (cb) => cb.notNull())
47
+ .addColumn('jira_keys_json', c.json, (cb) => cb.notNull())
48
+ .addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
49
+ .addColumn('attempts_count', c.int, (cb) => cb.notNull().defaultTo(0))
50
+ .addColumn('flaky', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0))
51
+ .addColumn('duration_ms', c.int)
52
+ .addColumn('error_message', 'text')
53
+ .addColumn('error_stack', 'text')
54
+ .addColumn('started_at', c.ts)
55
+ .addColumn('finished_at', c.ts), c).execute();
56
+ await index(db, 'uq_scenarios_run_natural', 'scenarios', ['run_id', 'natural_key'], true);
57
+ await index(db, 'idx_scenarios_fingerprint', 'scenarios', ['fingerprint']);
58
+ await index(db, 'idx_scenarios_run_status', 'scenarios', ['run_id', 'status']);
59
+ await index(db, 'idx_scenarios_project_fp', 'scenarios', ['project_id', 'fingerprint']);
60
+ await fk(fk(table(db, 'scenario_attempts', c), 'scenario_id', 'scenarios', c), 'run_id', 'runs', c)
61
+ .addColumn('attempt', c.int, (cb) => cb.notNull().defaultTo(0))
62
+ .addColumn('test_case_started_id', 'text', (cb) => cb.notNull())
63
+ .addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
64
+ .addColumn('duration_ms', c.int)
65
+ .addColumn('error_message', 'text')
66
+ .addColumn('error_stack', 'text')
67
+ .addColumn('will_be_retried', c.bool, (cb) => cb.notNull().defaultTo(c.pg ? false : 0))
68
+ .addColumn('worker_index', c.int)
69
+ .addColumn('started_at', c.ts)
70
+ .addColumn('finished_at', c.ts)
71
+ .addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
72
+ .execute();
73
+ await index(db, 'uq_attempts_run_tcs', 'scenario_attempts', ['run_id', 'test_case_started_id'], true);
74
+ await index(db, 'idx_attempts_scenario', 'scenario_attempts', ['scenario_id', 'attempt']);
75
+ await fk(fk(fk(table(db, 'steps', c), 'attempt_id', 'scenario_attempts', c), 'scenario_id', 'scenarios', c), 'run_id', 'runs', c)
76
+ .addColumn('step_index', c.int, (cb) => cb.notNull())
77
+ .addColumn('kind', 'text', (cb) => cb.notNull().defaultTo('step'))
78
+ .addColumn('hook_type', 'text')
79
+ .addColumn('keyword', 'text')
80
+ .addColumn('text', 'text', (cb) => cb.notNull())
81
+ .addColumn('argument_json', c.json)
82
+ .addColumn('status', 'text', (cb) => cb.notNull().defaultTo('unknown'))
83
+ .addColumn('duration_ms', c.int)
84
+ .addColumn('error_message', 'text')
85
+ .addColumn('error_stack', 'text')
86
+ .addColumn('definition_location', 'text')
87
+ .addColumn('test_step_id', 'text', (cb) => cb.notNull())
88
+ .addColumn('pickle_step_id', 'text')
89
+ .addColumn('layer_hint', 'text')
90
+ .addColumn('api_snapshot_json', c.json)
91
+ .addColumn('perf_json', c.json)
92
+ .addColumn('started_at', c.ts)
93
+ .addColumn('finished_at', c.ts)
94
+ .addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
95
+ .execute();
96
+ await index(db, 'uq_steps_attempt_test_step', 'steps', ['attempt_id', 'test_step_id'], true);
97
+ await index(db, 'idx_steps_scenario_index', 'steps', ['scenario_id', 'step_index']);
98
+ await fk(fk(fk(fk(table(db, 'artifacts', c), 'run_id', 'runs', c), 'scenario_id', 'scenarios', c, {
99
+ nullable: true,
100
+ }), 'attempt_id', 'scenario_attempts', c, { nullable: true }), 'step_id', 'steps', c, { nullable: true, onDelete: 'set null' })
101
+ .addColumn('step_index', c.int)
102
+ .addColumn('kind', 'text', (cb) => cb.notNull())
103
+ .addColumn('phase', 'text')
104
+ .addColumn('media_type', 'text', (cb) => cb.notNull().defaultTo('application/octet-stream'))
105
+ .addColumn('file_name', 'text', (cb) => cb.notNull())
106
+ .addColumn('rel_path', 'text', (cb) => cb.notNull().unique())
107
+ .addColumn('size_bytes', c.int, (cb) => cb.notNull().defaultTo(0))
108
+ .addColumn('sha256', 'text')
109
+ .addColumn('width', c.int)
110
+ .addColumn('height', c.int)
111
+ .addColumn('meta_json', c.json)
112
+ .addColumn('created_at', c.ts, (cb) => cb.notNull().defaultTo(c.now))
113
+ .execute();
114
+ await index(db, 'idx_artifacts_attempt_step_phase', 'artifacts', [
115
+ 'attempt_id',
116
+ 'step_index',
117
+ 'phase',
118
+ ]);
119
+ await index(db, 'idx_artifacts_scenario_kind', 'artifacts', ['scenario_id', 'kind']);
120
+ }
121
+ export async function down(db) {
122
+ await dropTables(db, ['artifacts', 'steps', 'scenario_attempts', 'scenarios', 'runs']);
123
+ }
124
+ //# sourceMappingURL=0002_results.js.map