@sdods/db 0.2.0 → 0.2.1

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/driver.d.ts CHANGED
@@ -5,6 +5,15 @@ export interface DriverConfig {
5
5
  databaseUrl?: string;
6
6
  }
7
7
  export declare const DEFAULT_SQLITE_PATH = ".sdods/sdods.db";
8
+ /**
9
+ * Nearest ancestor of `start` that looks like an SDODS workspace, or null.
10
+ *
11
+ * The default sqlite path is relative, and resolving it against the current directory meant every
12
+ * command opened a *different* database depending on where it was run from. `sdods serve` started
13
+ * inside a run's artifacts folder would silently create an empty database there and then reject
14
+ * every login, because the users live in the workspace database one or four levels up.
15
+ */
16
+ export declare function findWorkspaceRoot(start?: string): string | null;
8
17
  /** Resolve the platform database driver from the environment (`DB_DRIVER`, `SQLITE_PATH`, `DATABASE_URL`). */
9
18
  export declare function resolveDriverConfig(env?: NodeJS.ProcessEnv): DriverConfig;
10
19
  //# sourceMappingURL=driver.d.ts.map
package/dist/driver.js CHANGED
@@ -1,4 +1,29 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
1
3
  export const DEFAULT_SQLITE_PATH = '.sdods/sdods.db';
4
+ // Mirrors the markers ProjectRegistry.findRepoRoot looks for. Duplicated rather than imported
5
+ // because @sdods/db must not depend on @sdods/core (core already depends on db).
6
+ const WORKSPACE_MARKERS = ['sdods.workspace.yaml', 'sdods.config.ts', 'sdods.config.json'];
7
+ /**
8
+ * Nearest ancestor of `start` that looks like an SDODS workspace, or null.
9
+ *
10
+ * The default sqlite path is relative, and resolving it against the current directory meant every
11
+ * command opened a *different* database depending on where it was run from. `sdods serve` started
12
+ * inside a run's artifacts folder would silently create an empty database there and then reject
13
+ * every login, because the users live in the workspace database one or four levels up.
14
+ */
15
+ export function findWorkspaceRoot(start = process.cwd()) {
16
+ let dir = resolve(start);
17
+ for (let i = 0; i < 12; i++) {
18
+ if (WORKSPACE_MARKERS.some((m) => existsSync(join(dir, m))))
19
+ return dir;
20
+ const parent = dirname(dir);
21
+ if (parent === dir)
22
+ break;
23
+ dir = parent;
24
+ }
25
+ return null;
26
+ }
2
27
  /** Resolve the platform database driver from the environment (`DB_DRIVER`, `SQLITE_PATH`, `DATABASE_URL`). */
3
28
  export function resolveDriverConfig(env = process.env) {
4
29
  const driver = (env.DB_DRIVER ?? 'sqlite').toLowerCase();
@@ -11,6 +36,15 @@ export function resolveDriverConfig(env = process.env) {
11
36
  if (driver !== 'sqlite') {
12
37
  throw new Error(`Unknown DB_DRIVER "${env.DB_DRIVER}". Use sqlite or postgres.`);
13
38
  }
14
- return { driver: 'sqlite', sqlitePath: env.SQLITE_PATH ?? DEFAULT_SQLITE_PATH };
39
+ // An explicit SQLITE_PATH is taken at face value: the caller said where they want it. Only the
40
+ // default is anchored to the workspace, so `sdods serve` and `sdods users` agree on one database
41
+ // no matter which subdirectory they run from.
42
+ if (env.SQLITE_PATH)
43
+ return { driver: 'sqlite', sqlitePath: env.SQLITE_PATH };
44
+ const root = findWorkspaceRoot();
45
+ return {
46
+ driver: 'sqlite',
47
+ sqlitePath: root ? join(root, DEFAULT_SQLITE_PATH) : DEFAULT_SQLITE_PATH,
48
+ };
15
49
  }
16
50
  //# sourceMappingURL=driver.js.map
@@ -375,18 +375,6 @@ export class IngestSession {
375
375
  const meta = json;
376
376
  if (meta.dir)
377
377
  a.dir = meta.dir;
378
- // The runtime resolved the module from the project config (moduleForFeature), which is
379
- // authoritative; the scenario row was inserted earlier with moduleFromUri(), a directory
380
- // guess that is wrong whenever a module's `path` differs from its `name` (demo-shop's
381
- // `posts-api` lives in features/api/, so the guess yields "api" and never joins to
382
- // modules.name). Correct it now that the meta attachment has arrived.
383
- if (meta.module) {
384
- await this.ctx.adb.db
385
- .updateTable('scenarios')
386
- .set({ module: meta.module })
387
- .where('id', '=', a.scenarioId)
388
- .execute();
389
- }
390
378
  }
391
379
  else if (json) {
392
380
  const stepIndex = parsed.stepIndex;
@@ -105,5 +105,6 @@ export declare function listApiTokens(db: Kysely<Database>, userId?: string): Pr
105
105
  revokedAt: string | null;
106
106
  createdAt: string | null;
107
107
  }[]>;
108
- export declare function revokeApiToken(db: Kysely<Database>, id: string): Promise<void>;
108
+ /** True when a live token was revoked; false when the id is unknown or already revoked. */
109
+ export declare function revokeApiToken(db: Kysely<Database>, id: string): Promise<boolean>;
109
110
  //# sourceMappingURL=auth.d.ts.map
@@ -247,12 +247,14 @@ export async function listApiTokens(db, userId) {
247
247
  createdAt: readTs(r.created_at),
248
248
  }));
249
249
  }
250
+ /** True when a live token was revoked; false when the id is unknown or already revoked. */
250
251
  export async function revokeApiToken(db, id) {
251
- await db
252
+ const res = await db
252
253
  .updateTable('api_tokens')
253
254
  .set({ revoked_at: nowIso() })
254
255
  .where('id', '=', id)
255
256
  .where('revoked_at', 'is', null)
256
- .execute();
257
+ .executeTakeFirst();
258
+ return Number(res?.numUpdatedRows ?? 0) > 0;
257
259
  }
258
260
  //# sourceMappingURL=auth.js.map
@@ -74,10 +74,10 @@ export declare function upsertEnvironment(db: Kysely<Database>, driver: Driver,
74
74
  }): Promise<string>;
75
75
  export declare function getEnvironment(db: Kysely<Database>, projectId: string, name: string): Promise<{
76
76
  id: string;
77
- name: string;
78
- config_json: Record<string, unknown>;
79
77
  created_at: string;
80
78
  updated_at: string;
79
+ name: string;
80
+ config_json: Record<string, unknown>;
81
81
  project_id: string;
82
82
  base_url: string | null;
83
83
  api_base_url: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/db",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "SDODS platform database: one schema on SQLite or Postgres, migrations, result ingest and insights.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -23,7 +23,7 @@
23
23
  "node": ">=22"
24
24
  },
25
25
  "dependencies": {
26
- "@sdods/contracts": "0.2.0",
26
+ "@sdods/contracts": "0.2.1",
27
27
  "better-sqlite3": "^13.0.3",
28
28
  "kysely": "^0.29.5",
29
29
  "pg": "^8.23.0",
@@ -1,14 +0,0 @@
1
- import type { LocatorCounter } from './finalize.js';
2
- import { type IngestContext } from './types.js';
3
- export interface PwJsonCounts {
4
- scenarios: number;
5
- attempts: number;
6
- steps: number;
7
- artifacts: number;
8
- }
9
- /** Ingest a Playwright `json` reporter file (recorded layer / plain specs) into the same tables. */
10
- export declare function ingestPwJson(ctx: IngestContext, file: string, locatorCounters: Map<string, LocatorCounter>): Promise<PwJsonCounts & {
11
- startedAt: string | null;
12
- finishedAt: string | null;
13
- }>;
14
- //# sourceMappingURL=pw-json-ingest.d.ts.map
@@ -1,259 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { basename, dirname, isAbsolute, join, relative } from 'node:path';
3
- import { fingerprint as makeFingerprint, newId, parseRunnerProjectName } from '@sdods/contracts/ids';
4
- import { enc, nowIso } from '../col.js';
5
- import { artifactKindFor, parseAttachmentName, storeFile, targetFileFor } from './attachments.js';
6
- import { moduleFromUri, pwStatus, selectorFromError } from './types.js';
7
- /** Ingest a Playwright `json` reporter file (recorded layer / plain specs) into the same tables. */
8
- export async function ingestPwJson(ctx, file, locatorCounters) {
9
- const report = JSON.parse(readFileSync(file, 'utf8'));
10
- const counts = { scenarios: 0, attempts: 0, steps: 0, artifacts: 0 };
11
- let startedAt = null;
12
- let finishedAt = null;
13
- const { db, driver } = ctx.adb;
14
- const rootDir = report.config?.rootDir ?? dirname(file);
15
- const walk = async (suite) => {
16
- for (const s of suite.suites ?? [])
17
- await walk(s);
18
- for (const spec of suite.specs ?? []) {
19
- for (const test of spec.tests ?? []) {
20
- const pwProject = test.projectName ?? test.projectId ?? 'recorded';
21
- const parts = parseRunnerProjectName(pwProject);
22
- const slug = parts?.project ?? ctx.projectSlug;
23
- const layer = parts?.layer ?? 'recorded';
24
- const browser = parts?.browser ?? null;
25
- const specFile = String(spec.file ?? suite.file ?? 'unknown.spec.ts').replace(/\\/g, '/');
26
- const title = spec.title ?? test.title ?? 'untitled';
27
- const fp = makeFingerprint({
28
- project: slug,
29
- featureUri: specFile,
30
- scenarioName: title,
31
- exampleIndex: null,
32
- layer,
33
- });
34
- const naturalKey = `${pwProject}:${test.id ?? `${specFile}:${spec.line ?? 0}`}`;
35
- const tags = (spec.tags ?? test.tags ?? []).map((t) => t.startsWith('@') ? t : `@${t}`);
36
- let scenarioId;
37
- const existing = await db
38
- .selectFrom('scenarios')
39
- .select(['id'])
40
- .where('run_id', '=', ctx.runId)
41
- .where('natural_key', '=', naturalKey)
42
- .executeTakeFirst();
43
- if (existing)
44
- scenarioId = existing.id;
45
- else {
46
- scenarioId = newId();
47
- await db
48
- .insertInto('scenarios')
49
- .values({
50
- id: scenarioId,
51
- run_id: ctx.runId,
52
- project_id: ctx.projectId,
53
- natural_key: naturalKey,
54
- fingerprint: fp,
55
- source: 'pw-json',
56
- feature_uri: specFile,
57
- feature_name: suite.title ?? basename(specFile),
58
- scenario_name: title,
59
- module: moduleFromUri(specFile),
60
- examples_row: null,
61
- pw_project: pwProject,
62
- layer,
63
- browser,
64
- suite_tag: tags.find((t) => ['@smoke', '@regression', '@sanity'].includes(t)) ?? null,
65
- tags_json: enc.json(tags),
66
- jira_keys_json: enc.json(tags.filter((t) => t.startsWith('@jira:')).map((t) => t.slice(6))),
67
- status: 'unknown',
68
- attempts_count: 0,
69
- flaky: enc.bool(driver, false),
70
- duration_ms: null,
71
- error_message: null,
72
- error_stack: null,
73
- started_at: null,
74
- finished_at: null,
75
- created_at: nowIso(),
76
- updated_at: nowIso(),
77
- })
78
- .execute();
79
- counts.scenarios++;
80
- }
81
- for (const result of test.results ?? []) {
82
- const retry = Number(result.retry ?? 0);
83
- const tcsId = `${test.id ?? naturalKey}#${retry}`;
84
- const status = pwStatus(result.status);
85
- const started = result.startTime ? new Date(result.startTime).toISOString() : null;
86
- const finished = started && typeof result.duration === 'number'
87
- ? new Date(Date.parse(started) + result.duration).toISOString()
88
- : null;
89
- startedAt =
90
- !startedAt || (started && started < startedAt) ? (started ?? startedAt) : startedAt;
91
- finishedAt =
92
- !finishedAt || (finished && finished > finishedAt)
93
- ? (finished ?? finishedAt)
94
- : finishedAt;
95
- const errorMessage = result.error?.message ?? result.errors?.[0]?.message ?? null;
96
- const errorStack = result.error?.stack ?? result.errors?.[0]?.stack ?? null;
97
- if (status === 'failed' || status === 'timedOut') {
98
- const selector = selectorFromError(errorStack ?? errorMessage);
99
- if (selector) {
100
- const c = locatorCounters.get(selector) ?? { fails: 0, heals: 0, uses: 0 };
101
- c.fails++;
102
- c.uses++;
103
- locatorCounters.set(selector, c);
104
- }
105
- }
106
- let attemptId;
107
- const ex = await db
108
- .selectFrom('scenario_attempts')
109
- .select(['id'])
110
- .where('run_id', '=', ctx.runId)
111
- .where('test_case_started_id', '=', tcsId)
112
- .executeTakeFirst();
113
- const values = {
114
- attempt: retry,
115
- status,
116
- duration_ms: typeof result.duration === 'number' ? result.duration : null,
117
- error_message: errorMessage ? errorMessage.split('\n')[0].slice(0, 2000) : null,
118
- error_stack: errorStack,
119
- will_be_retried: enc.bool(driver, (status === 'failed' || status === 'timedOut') && retry < Number(test.retries ?? 0)),
120
- worker_index: typeof result.workerIndex === 'number' ? result.workerIndex : null,
121
- started_at: started,
122
- finished_at: finished,
123
- };
124
- if (ex) {
125
- attemptId = ex.id;
126
- await db
127
- .updateTable('scenario_attempts')
128
- .set(values)
129
- .where('id', '=', attemptId)
130
- .execute();
131
- }
132
- else {
133
- attemptId = newId();
134
- await db
135
- .insertInto('scenario_attempts')
136
- .values({
137
- id: attemptId,
138
- scenario_id: scenarioId,
139
- run_id: ctx.runId,
140
- test_case_started_id: tcsId,
141
- created_at: nowIso(),
142
- ...values,
143
- })
144
- .execute();
145
- counts.attempts++;
146
- }
147
- // steps (category test.step, top level)
148
- const steps = (result.steps ?? []).filter((s) => !s.category || s.category === 'test.step');
149
- let idx = 0;
150
- for (const st of steps) {
151
- const testStepId = `${tcsId}-step-${idx}`;
152
- const stepStatus = st.error ? 'failed' : 'passed';
153
- const sv = {
154
- step_index: idx,
155
- kind: 'step',
156
- hook_type: null,
157
- keyword: null,
158
- text: String(st.title ?? ''),
159
- argument_json: null,
160
- status: stepStatus,
161
- duration_ms: typeof st.duration === 'number' ? st.duration : null,
162
- error_message: st.error?.message
163
- ? String(st.error.message).split('\n')[0].slice(0, 2000)
164
- : null,
165
- error_stack: st.error?.stack ?? null,
166
- definition_location: st.location ? `${st.location.file}:${st.location.line}` : null,
167
- pickle_step_id: null,
168
- layer_hint: 'ui',
169
- started_at: null,
170
- finished_at: null,
171
- };
172
- const es = await db
173
- .selectFrom('steps')
174
- .select(['id'])
175
- .where('attempt_id', '=', attemptId)
176
- .where('test_step_id', '=', testStepId)
177
- .executeTakeFirst();
178
- if (es)
179
- await db.updateTable('steps').set(sv).where('id', '=', es.id).execute();
180
- else {
181
- await db
182
- .insertInto('steps')
183
- .values({
184
- id: newId(),
185
- attempt_id: attemptId,
186
- scenario_id: scenarioId,
187
- run_id: ctx.runId,
188
- test_step_id: testStepId,
189
- api_snapshot_json: null,
190
- perf_json: null,
191
- created_at: nowIso(),
192
- ...sv,
193
- })
194
- .execute();
195
- counts.steps++;
196
- }
197
- idx++;
198
- }
199
- // attachments
200
- const dir = join(ctx.runDir, slug, fp, `r${retry}`);
201
- for (const att of result.attachments ?? []) {
202
- const name = att.name ?? 'attachment';
203
- const mediaType = att.contentType ?? 'application/octet-stream';
204
- const parsed = parseAttachmentName(name);
205
- const filePath = att.path
206
- ? isAbsolute(att.path)
207
- ? att.path
208
- : join(rootDir, att.path)
209
- : undefined;
210
- const bytes = att.body ? Buffer.from(att.body, 'base64') : null;
211
- if (!bytes && !(filePath && existsSync(filePath)))
212
- continue;
213
- const stored = storeFile(ctx.artifactsRoot, dir, targetFileFor(parsed, name, mediaType), bytes, filePath);
214
- if (!stored)
215
- continue;
216
- const { kind, phase, stepIndex } = artifactKindFor(parsed, mediaType);
217
- const ea = await db
218
- .selectFrom('artifacts')
219
- .select(['id'])
220
- .where('rel_path', '=', stored.relPath)
221
- .executeTakeFirst();
222
- const av = {
223
- run_id: ctx.runId,
224
- scenario_id: scenarioId,
225
- attempt_id: attemptId,
226
- step_id: null,
227
- step_index: stepIndex,
228
- kind,
229
- phase,
230
- media_type: mediaType,
231
- file_name: name,
232
- size_bytes: stored.sizeBytes,
233
- sha256: stored.sha256,
234
- width: stored.width,
235
- height: stored.height,
236
- meta_json: enc.json({
237
- original: name,
238
- source: filePath ? relative(rootDir, filePath) : 'inline',
239
- }),
240
- };
241
- if (ea)
242
- await db.updateTable('artifacts').set(av).where('id', '=', ea.id).execute();
243
- else {
244
- await db
245
- .insertInto('artifacts')
246
- .values({ id: newId(), rel_path: stored.relPath, created_at: nowIso(), ...av })
247
- .execute();
248
- counts.artifacts++;
249
- }
250
- }
251
- }
252
- }
253
- }
254
- };
255
- for (const s of report.suites ?? [])
256
- await walk(s);
257
- return { ...counts, startedAt, finishedAt };
258
- }
259
- //# sourceMappingURL=pw-json-ingest.js.map