@sdods/db 0.9.0 → 0.11.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.
@@ -1,4 +1,4 @@
1
- import type { RunTotals } from '@sdods/contracts/types';
1
+ import type { GateResult, RunTotals } from '@sdods/contracts/types';
2
2
  import type { SdodsDb } from '../create-db.js';
3
3
  export interface LocatorCounter {
4
4
  fails: number;
@@ -25,4 +25,21 @@ export declare function finalizeRun(input: FinalizeInput): Promise<{
25
25
  totals: RunTotals;
26
26
  status: string;
27
27
  }>;
28
+ /** The gate verdict the CLI wrote into a run directory (`gates.json`), when there is one. */
29
+ export declare function readGateVerdict(runDir: string): GateResult | undefined;
30
+ /** `value` when it has the shape of a gate verdict. */
31
+ export declare function gateResultOf(value: unknown): GateResult | undefined;
32
+ /**
33
+ * Records a process gate verdict on the run row: under `totals_json.gates`, and — when a gate failed
34
+ * — as the run's status, exit code and error. A run whose scenarios all passed and whose gates did
35
+ * not is a failed run; leaving it `passed` is exactly the claim the gate exists to stop. Used by
36
+ * ingest (CLI runs, merged shards) and by the server's run manager, so both record it the same way.
37
+ * Returns the run's status afterwards.
38
+ */
39
+ export declare function recordGateVerdict(adb: SdodsDb, input: {
40
+ runId: string;
41
+ gates: GateResult;
42
+ cancelled?: boolean;
43
+ exitCode?: number | null;
44
+ }): Promise<string | undefined>;
28
45
  //# sourceMappingURL=finalize.d.ts.map
@@ -1,3 +1,6 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runFiles } from '@sdods/contracts/names';
1
4
  import { enc, nowIso, readBool, readJson, readTs } from '../col.js';
2
5
  import { bumpLocatorStats, recomputeFlakyStats } from '../repos/improvement.js';
3
6
  import { emptyTotals } from './types.js';
@@ -129,4 +132,68 @@ export async function finalizeRun(input) {
129
132
  void readBool;
130
133
  return { totals, status };
131
134
  }
135
+ /** The gate verdict the CLI wrote into a run directory (`gates.json`), when there is one. */
136
+ export function readGateVerdict(runDir) {
137
+ const file = join(runDir, runFiles.gates);
138
+ if (!existsSync(file))
139
+ return undefined;
140
+ try {
141
+ return gateResultOf(JSON.parse(readFileSync(file, 'utf8')));
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ }
147
+ /** `value` when it has the shape of a gate verdict. */
148
+ export function gateResultOf(value) {
149
+ const v = value;
150
+ return v && typeof v === 'object' && typeof v.process === 'string' && Array.isArray(v.rows)
151
+ ? v
152
+ : undefined;
153
+ }
154
+ const GATE_ERROR_PREFIX = 'GATE_FAILED:';
155
+ /**
156
+ * Records a process gate verdict on the run row: under `totals_json.gates`, and — when a gate failed
157
+ * — as the run's status, exit code and error. A run whose scenarios all passed and whose gates did
158
+ * not is a failed run; leaving it `passed` is exactly the claim the gate exists to stop. Used by
159
+ * ingest (CLI runs, merged shards) and by the server's run manager, so both record it the same way.
160
+ * Returns the run's status afterwards.
161
+ */
162
+ export async function recordGateVerdict(adb, input) {
163
+ const { db } = adb;
164
+ const { runId, gates } = input;
165
+ const row = await db
166
+ .selectFrom('runs')
167
+ .select(['status', 'totals_json', 'exit_code', 'error_text'])
168
+ .where('id', '=', runId)
169
+ .executeTakeFirst();
170
+ if (!row)
171
+ return undefined;
172
+ const prev = readJson(row.totals_json) ?? {};
173
+ const cancelled = input.cancelled || row.status === 'cancelled';
174
+ const failed = gates.rows.filter((r) => !r.passed);
175
+ const breached = !gates.passed && !cancelled;
176
+ const status = breached ? 'failed' : row.status;
177
+ const staleGateError = !breached && row.error_text?.startsWith(GATE_ERROR_PREFIX);
178
+ await db
179
+ .updateTable('runs')
180
+ .set({
181
+ totals_json: enc.json({ ...prev, gates }),
182
+ status,
183
+ ...(breached
184
+ ? {
185
+ exit_code: input.exitCode || row.exit_code || 1,
186
+ error_text: `${GATE_ERROR_PREFIX} process "${gates.process}" did not meet ${failed
187
+ .map((r) => `${r.gate} (${r.actual}, needs ${r.threshold})`)
188
+ .join('; ')}`,
189
+ }
190
+ : staleGateError
191
+ ? { error_text: null }
192
+ : {}),
193
+ updated_at: nowIso(),
194
+ })
195
+ .where('id', '=', runId)
196
+ .execute();
197
+ return status;
198
+ }
132
199
  //# sourceMappingURL=finalize.js.map
@@ -5,7 +5,7 @@ import { readJson } from '../col.js';
5
5
  import { ensureProject, getProjectBySlug } from '../repos/projects.js';
6
6
  import { deleteRunChildren, upsertRun } from '../repos/runs.js';
7
7
  import { IngestSession } from './cucumber-ingest.js';
8
- import { finalizeRun } from './finalize.js';
8
+ import { finalizeRun, gateResultOf, readGateVerdict, recordGateVerdict, } from './finalize.js';
9
9
  import { fileSha256, parseNdjson } from './parse-ndjson.js';
10
10
  import { ingestRunnerJson } from './runner-json-ingest.js';
11
11
  export * from './types.js';
@@ -165,7 +165,7 @@ export async function ingestRun(adb, opts) {
165
165
  runFinishedAt = r.finishedAt;
166
166
  filesIngested.push(key);
167
167
  }
168
- const { totals, status } = await finalizeRun({
168
+ const finalized = await finalizeRun({
169
169
  adb,
170
170
  runId: opts.runId,
171
171
  projectId: project.id,
@@ -175,11 +175,31 @@ export async function ingestRun(adb, opts) {
175
175
  ingestedFiles: filesIngested,
176
176
  exitCode: manifest?.exitCode ?? null,
177
177
  });
178
+ const { totals } = finalized;
179
+ let { status } = finalized;
180
+ // The same verdict a server-started run gets: CLI runs and merged shards look alike in the DB.
181
+ // A re-ingest with nothing to judge keeps the verdict already recorded, rather than resetting the
182
+ // run to the status its scenarios alone would give it.
183
+ const gates = opts.gates === null
184
+ ? undefined
185
+ : typeof opts.gates === 'object'
186
+ ? opts.gates
187
+ : (readGateVerdict(runDir) ??
188
+ (typeof opts.gates === 'function' ? opts.gates(totals) : undefined) ??
189
+ gateResultOf(prevTotals.gates));
190
+ if (gates)
191
+ status =
192
+ (await recordGateVerdict(adb, {
193
+ runId: opts.runId,
194
+ gates,
195
+ exitCode: manifest?.exitCode ?? null,
196
+ })) ?? status;
178
197
  return {
179
198
  runId: opts.runId,
180
199
  projectSlug,
181
200
  totals,
182
201
  status,
202
+ gates,
183
203
  scenarios: counts.scenarios,
184
204
  attempts: counts.attempts,
185
205
  steps: counts.steps,
@@ -1,4 +1,4 @@
1
- import type { RunManifest, RunTotals, SuiteStatus } from '@sdods/contracts/types';
1
+ import type { GateResult, RunManifest, RunTotals, SuiteStatus } from '@sdods/contracts/types';
2
2
  import type { SdodsDb } from '../create-db.js';
3
3
  export type { RunManifest, RunTotals, SuiteStatus };
4
4
  export interface IngestRunOptions {
@@ -14,12 +14,22 @@ export interface IngestRunOptions {
14
14
  /** git/ci/trigger overrides when there is no manifest */
15
15
  trigger?: string;
16
16
  env?: string;
17
+ /**
18
+ * The process gate verdict to record on the run. The `gates.json` the CLI wrote into the run
19
+ * directory (`sdods run --process`, `sdods report merge --process`) wins; without one, a function
20
+ * here judges the gates over the totals ingest computed (the database cannot evaluate them
21
+ * itself: the judge lives in core, which depends on this package); failing that, the verdict
22
+ * already recorded on the run is kept. A `GateResult` records that verdict; `null` records none.
23
+ */
24
+ gates?: GateResult | null | ((totals: RunTotals) => GateResult | undefined);
17
25
  }
18
26
  export interface IngestResult {
19
27
  runId: string;
20
28
  projectSlug: string;
21
29
  totals: RunTotals;
22
30
  status: string;
31
+ /** The process gate verdict recorded on the run, when there was one. */
32
+ gates?: GateResult;
23
33
  scenarios: number;
24
34
  attempts: number;
25
35
  steps: number;
@@ -0,0 +1,9 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Col } from '../col.js';
3
+ /**
4
+ * Self-service profile: a free-text display name next to the immutable username. Nullable, so
5
+ * existing users keep showing their username until they set one.
6
+ */
7
+ export declare function up(db: Kysely<any>, c: Col): Promise<void>;
8
+ export declare function down(db: Kysely<any>, _c: Col): Promise<void>;
9
+ //# sourceMappingURL=0009_user_profile.d.ts.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Self-service profile: a free-text display name next to the immutable username. Nullable, so
3
+ * existing users keep showing their username until they set one.
4
+ */
5
+ export async function up(db, c) {
6
+ await db.schema.alterTable('users').addColumn('display_name', c.text).execute();
7
+ }
8
+ export async function down(db, _c) {
9
+ await db.schema.alterTable('users').dropColumn('display_name').execute();
10
+ }
11
+ //# sourceMappingURL=0009_user_profile.js.map
@@ -6,6 +6,7 @@ import * as m0005 from './0005_integrations_agents_audit.js';
6
6
  import * as m0006 from './0006_schedules.js';
7
7
  import * as m0007 from './0007_hierarchy.js';
8
8
  import * as m0008 from './0008_runner_naming.js';
9
+ import * as m0009 from './0009_user_profile.js';
9
10
  /**
10
11
  * Static registry (bundler-friendly, no runtime file discovery). Order matters: Kysely sorts by key.
11
12
  */
@@ -18,5 +19,6 @@ export const MIGRATIONS = {
18
19
  '0006_schedules': m0006,
19
20
  '0007_hierarchy': m0007,
20
21
  '0008_runner_naming': m0008,
22
+ '0009_user_profile': m0009,
21
23
  };
22
24
  //# sourceMappingURL=index.js.map
@@ -16,6 +16,7 @@ export declare function getUserByUsername(db: Kysely<Database>, username: string
16
16
  id: string;
17
17
  username: string;
18
18
  email: string | null;
19
+ displayName: string | null;
19
20
  passwordHash: string;
20
21
  role: Role;
21
22
  active: boolean;
@@ -27,6 +28,7 @@ export declare function getUserById(db: Kysely<Database>, id: string): Promise<{
27
28
  id: string;
28
29
  username: string;
29
30
  email: string | null;
31
+ displayName: string | null;
30
32
  passwordHash: string;
31
33
  role: Role;
32
34
  active: boolean;
@@ -38,6 +40,7 @@ export declare function listUsers(db: Kysely<Database>): Promise<{
38
40
  id: string;
39
41
  username: string;
40
42
  email: string | null;
43
+ displayName: string | null;
41
44
  passwordHash: string;
42
45
  role: Role;
43
46
  active: boolean;
@@ -50,6 +53,7 @@ export declare function updateUser(db: Kysely<Database>, driver: Driver, id: str
50
53
  role?: Role;
51
54
  active?: boolean;
52
55
  email?: string | null;
56
+ displayName?: string | null;
53
57
  lastLoginAt?: string;
54
58
  }): Promise<void>;
55
59
  export declare function countUsers(db: Kysely<Database>): Promise<number>;
@@ -79,8 +83,28 @@ export declare function getSession(db: Kysely<Database>, token: string): Promise
79
83
  } | null>;
80
84
  export declare function touchSession(db: Kysely<Database>, token: string, ttlMs: number): Promise<void>;
81
85
  export declare function deleteSession(db: Kysely<Database>, token: string): Promise<void>;
82
- /** Sign a user out everywhere, e.g. after their password changes. */
83
- export declare function deleteSessionsForUser(db: Kysely<Database>, userId: string): Promise<void>;
86
+ /**
87
+ * Sign a user out everywhere, e.g. after their password changes. `exceptToken` keeps the session
88
+ * that made the change, so saving a new password does not also sign out the person saving it.
89
+ * Returns how many sessions were removed.
90
+ */
91
+ export declare function deleteSessionsForUser(db: Kysely<Database>, userId: string, opts?: {
92
+ exceptToken?: string;
93
+ }): Promise<number>;
94
+ /**
95
+ * A user's live sessions, newest activity first. `id` is the stored hash of the cookie token,
96
+ * never the token itself, so listing sessions cannot be used to take one over.
97
+ */
98
+ export declare function listSessionsForUser(db: Kysely<Database>, userId: string): Promise<{
99
+ id: string;
100
+ ip: string | null;
101
+ userAgent: string | null;
102
+ createdAt: string;
103
+ lastSeenAt: string;
104
+ expiresAt: string;
105
+ }[]>;
106
+ /** Revoke one of a user's sessions by its listed id. False when it is not theirs or already gone. */
107
+ export declare function deleteSessionById(db: Kysely<Database>, userId: string, id: string): Promise<boolean>;
84
108
  export declare function purgeExpiredSessions(db: Kysely<Database>): Promise<void>;
85
109
  export declare function createApiToken(db: Kysely<Database>, input: {
86
110
  userId: string;
@@ -33,6 +33,7 @@ export async function createUser(db, driver, input) {
33
33
  id,
34
34
  username: input.username,
35
35
  email: input.email ?? null,
36
+ display_name: null,
36
37
  password_hash: input.passwordHash,
37
38
  role_id: roles[input.role],
38
39
  active: enc.bool(driver, true),
@@ -84,6 +85,7 @@ export async function updateUser(db, driver, id, patch) {
84
85
  ...(roles && patch.role ? { role_id: roles[patch.role] } : {}),
85
86
  ...(patch.active !== undefined ? { active: enc.bool(driver, patch.active) } : {}),
86
87
  ...(patch.email !== undefined ? { email: patch.email } : {}),
88
+ ...(patch.displayName !== undefined ? { display_name: patch.displayName } : {}),
87
89
  ...(patch.lastLoginAt ? { last_login_at: patch.lastLoginAt } : {}),
88
90
  updated_at: nowIso(),
89
91
  })
@@ -119,6 +121,7 @@ function mapUser(row) {
119
121
  id: row.id,
120
122
  username: row.username,
121
123
  email: row.email,
124
+ displayName: (row.display_name ?? null),
122
125
  passwordHash: row.password_hash,
123
126
  role: row.role,
124
127
  active: readBool(row.active),
@@ -178,9 +181,47 @@ export async function touchSession(db, token, ttlMs) {
178
181
  export async function deleteSession(db, token) {
179
182
  await db.deleteFrom('sessions').where('id', '=', hashToken(token)).execute();
180
183
  }
181
- /** Sign a user out everywhere, e.g. after their password changes. */
182
- export async function deleteSessionsForUser(db, userId) {
183
- await db.deleteFrom('sessions').where('user_id', '=', userId).execute();
184
+ /**
185
+ * Sign a user out everywhere, e.g. after their password changes. `exceptToken` keeps the session
186
+ * that made the change, so saving a new password does not also sign out the person saving it.
187
+ * Returns how many sessions were removed.
188
+ */
189
+ export async function deleteSessionsForUser(db, userId, opts = {}) {
190
+ let q = db.deleteFrom('sessions').where('user_id', '=', userId);
191
+ if (opts.exceptToken)
192
+ q = q.where('id', '!=', hashToken(opts.exceptToken));
193
+ const res = await q.executeTakeFirst();
194
+ return Number(res?.numDeletedRows ?? 0);
195
+ }
196
+ /**
197
+ * A user's live sessions, newest activity first. `id` is the stored hash of the cookie token,
198
+ * never the token itself, so listing sessions cannot be used to take one over.
199
+ */
200
+ export async function listSessionsForUser(db, userId) {
201
+ const rows = await db
202
+ .selectFrom('sessions')
203
+ .selectAll()
204
+ .where('user_id', '=', userId)
205
+ .where('expires_at', '>', nowIso())
206
+ .orderBy('last_seen_at', 'desc')
207
+ .execute();
208
+ return rows.map((r) => ({
209
+ id: r.id,
210
+ ip: r.ip,
211
+ userAgent: r.user_agent,
212
+ createdAt: readTs(r.created_at),
213
+ lastSeenAt: readTs(r.last_seen_at),
214
+ expiresAt: readTs(r.expires_at),
215
+ }));
216
+ }
217
+ /** Revoke one of a user's sessions by its listed id. False when it is not theirs or already gone. */
218
+ export async function deleteSessionById(db, userId, id) {
219
+ const res = await db
220
+ .deleteFrom('sessions')
221
+ .where('id', '=', id)
222
+ .where('user_id', '=', userId)
223
+ .executeTakeFirst();
224
+ return Number(res?.numDeletedRows ?? 0) > 0;
184
225
  }
185
226
  export async function purgeExpiredSessions(db) {
186
227
  await db.deleteFrom('sessions').where('expires_at', '<', nowIso()).execute();
@@ -1,5 +1,6 @@
1
1
  import { enc, nowIso, readBool, readJson, readTs } from '../col.js';
2
2
  import { newId } from '../ids.js';
3
+ import { gateResultOf } from '../ingest/finalize.js';
3
4
  export async function upsertRun(db, _driver, r) {
4
5
  const existing = await db
5
6
  .selectFrom('runs')
@@ -116,6 +117,7 @@ export function mapRun(row) {
116
117
  finishedAt: readTs(row.finished_at) ?? undefined,
117
118
  durationMs: row.duration_ms ?? undefined,
118
119
  totals,
120
+ gates: gateResultOf(totalsRaw.gates),
119
121
  totalsRaw,
120
122
  artifactsDir: row.artifacts_dir ?? undefined,
121
123
  exitCode: row.exit_code ?? undefined,
package/dist/schema.d.ts CHANGED
@@ -337,6 +337,7 @@ export interface UsersTable {
337
337
  id: string;
338
338
  username: string;
339
339
  email: string | null;
340
+ display_name: string | null;
340
341
  password_hash: string;
341
342
  role_id: string;
342
343
  active: Bool;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/db",
3
- "version": "0.9.0",
3
+ "version": "0.11.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,19 +23,19 @@
23
23
  "node": ">=22"
24
24
  },
25
25
  "dependencies": {
26
- "@sdods/contracts": "0.9.0",
26
+ "@sdods/contracts": "0.11.1",
27
27
  "better-sqlite3": "^13.0.3",
28
28
  "kysely": "^0.29.5",
29
29
  "pg": "^8.23.0",
30
- "zod": "^4.5.4"
30
+ "zod": "^4.6.5"
31
31
  },
32
32
  "homepage": "https://sdods.com",
33
33
  "bugs": {
34
- "url": "https://github.com/siri1410/SDODS/issues"
34
+ "url": "https://github.com/YarlisAISolutions/SDODS/issues"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/siri1410/SDODS.git",
38
+ "url": "git+https://github.com/YarlisAISolutions/SDODS.git",
39
39
  "directory": "packages/db"
40
40
  },
41
41
  "keywords": [