karajan-code 2.19.0 → 2.19.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/README.md +2 -0
- package/package.json +9 -1
- package/packages/hu-board/README.md +69 -0
- package/packages/hu-board/package.json +23 -0
- package/packages/hu-board/public/app.js +4038 -0
- package/packages/hu-board/public/index.html +55 -0
- package/packages/hu-board/public/pipeline.css +132 -0
- package/packages/hu-board/public/pipeline.html +48 -0
- package/packages/hu-board/public/pipeline.js +200 -0
- package/packages/hu-board/public/styles.css +918 -0
- package/packages/hu-board/src/auth.js +92 -0
- package/packages/hu-board/src/boot-info.js +16 -0
- package/packages/hu-board/src/cleanup-zombies.js +142 -0
- package/packages/hu-board/src/command-runner.js +134 -0
- package/packages/hu-board/src/config-yaml.js +285 -0
- package/packages/hu-board/src/db.js +739 -0
- package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
- package/packages/hu-board/src/event-bus.js +58 -0
- package/packages/hu-board/src/format.js +83 -0
- package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
- package/packages/hu-board/src/journal-parser.js +180 -0
- package/packages/hu-board/src/plan-mutations.js +422 -0
- package/packages/hu-board/src/preflight.js +285 -0
- package/packages/hu-board/src/routes/api.js +1271 -0
- package/packages/hu-board/src/routes/pipeline.js +45 -0
- package/packages/hu-board/src/run-tracker.js +121 -0
- package/packages/hu-board/src/server.js +435 -0
- package/packages/hu-board/src/sync.js +626 -0
- package/packages/hu-board/src/token-store.js +57 -0
- package/packages/hu-board/src/zombie-reaper.js +207 -0
- package/src/audit/webperf-input.js +4 -7
- package/src/checks/dir-setup.js +5 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/commands/plan/generate.js +15 -0
- package/src/commands/run.js +19 -1
- package/src/commands/webperf.js +4 -6
- package/src/config/role-resolver.js +3 -1
- package/src/mcp/handlers/direct-handlers.js +3 -0
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/tools.js +4 -2
- package/src/prompts/spec-reviewer.js +66 -0
- package/src/roles/perf-role.js +4 -4
- package/src/roles/spec-reviewer-role.js +106 -0
- package/src/spec-review/refine-loop.js +58 -0
- package/src/spec-review/run-spec-review.js +70 -0
- package/src/utils/board-prompt-bridge.js +4 -5
- package/src/utils/display/spec-findings.js +44 -0
- package/src/utils/paths.js +15 -0
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdirSync, renameSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
/** @type {import('better-sqlite3').Database | null} */
|
|
7
|
+
let db = null;
|
|
8
|
+
|
|
9
|
+
// Per-process tmp dir for VITEST runs that forget to set KJ_HOME.
|
|
10
|
+
// Without this, a test that touches initDb() / upsertProject() drops
|
|
11
|
+
// rows into the developer's real `~/.karajan/hu-board.db` and the
|
|
12
|
+
// running board UI shows phantom projects (observed: "sess-1",
|
|
13
|
+
// "default", "Karajan Code" with 49 test fixture plans). Memoise so
|
|
14
|
+
// every consumer in the same process agrees on the path. The
|
|
15
|
+
// trailing `.karajan` segment keeps semantic parity with non-VITEST
|
|
16
|
+
// defaults so any caller assuming the path ends in `.karajan` still
|
|
17
|
+
// works.
|
|
18
|
+
let _vitestKjHome = null;
|
|
19
|
+
function vitestTmpKjHome() {
|
|
20
|
+
if (_vitestKjHome) return _vitestKjHome;
|
|
21
|
+
_vitestKjHome = join(
|
|
22
|
+
tmpdir(),
|
|
23
|
+
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
|
|
24
|
+
'.karajan'
|
|
25
|
+
);
|
|
26
|
+
return _vitestKjHome;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the KJ home directory, respecting KJ_HOME env var.
|
|
31
|
+
* Under VITEST without KJ_HOME, returns a per-process tmp dir to
|
|
32
|
+
* prevent accidental writes into the developer's real `~/.karajan/`.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function getKjHome() {
|
|
36
|
+
// KJC-TSK-0420: same precedence chain as src/utils/paths.js::resolveHome():
|
|
37
|
+
// KARAJAN_HOME (canonical) > KJ_HOME (legacy) > VITEST tmp > ~/.karajan.
|
|
38
|
+
// Kept inline rather than cross-workspace imported so the hu-board package
|
|
39
|
+
// does not gain a new src/ dep; the precedence behaviour is the same.
|
|
40
|
+
if (process.env.KARAJAN_HOME) return process.env.KARAJAN_HOME;
|
|
41
|
+
if (process.env.KJ_HOME) return process.env.KJ_HOME;
|
|
42
|
+
if (process.env.VITEST) return vitestTmpKjHome();
|
|
43
|
+
return join(process.env.HOME || '/root', '.karajan');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Canonical path to the HU Board's run-logs directory
|
|
48
|
+
* (`<karajan-home>/hu-board-runs/`). Centralised here so the four
|
|
49
|
+
* board callers (plan-mutations, command-runner, api.js GET routes
|
|
50
|
+
* for plan + commandId logs) and the garbage-collector all agree on
|
|
51
|
+
* the same string — KJC-TSK-0421.
|
|
52
|
+
*
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function getHuBoardRunsDir() {
|
|
56
|
+
return join(getKjHome(), 'hu-board-runs');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Bump when the schema gains a column / index / table that older
|
|
60
|
+
// Karajan versions cannot understand. A DB written by a newer Karajan
|
|
61
|
+
// refuses to open here so it does not get silently downgraded.
|
|
62
|
+
const SCHEMA_VERSION = 1;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tries to open the SQLite database; if it is corrupt, moves it aside
|
|
66
|
+
* and opens a fresh one. `fullScan` afterwards rebuilds the cache from
|
|
67
|
+
* the JSON files on disk — the DB is a cache, never the source of
|
|
68
|
+
* truth. Without this the board crashed at startup with a raw
|
|
69
|
+
* `SqliteError: database disk image is malformed` whenever the WAL
|
|
70
|
+
* checkpoint died in the wrong place.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dbPath
|
|
73
|
+
* @returns {import('better-sqlite3').Database}
|
|
74
|
+
*/
|
|
75
|
+
function openDbResilient(dbPath) {
|
|
76
|
+
try {
|
|
77
|
+
const handle = new Database(dbPath);
|
|
78
|
+
// better-sqlite3 opens lazily — the first pragma is what actually
|
|
79
|
+
// touches the file and surfaces "file is not a database". Probe
|
|
80
|
+
// `schema_version` (a built-in SQLite pragma) to force it.
|
|
81
|
+
handle.pragma("schema_version", { simple: true });
|
|
82
|
+
return handle;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
const code = err?.code || "";
|
|
85
|
+
const msg = err?.message || "";
|
|
86
|
+
if (code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB" || /malformed|not a database/i.test(msg)) {
|
|
87
|
+
const aside = `${dbPath}.corrupt-${Date.now()}`;
|
|
88
|
+
try { renameSync(dbPath, aside); } catch { /* may already be gone */ }
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn(`[hu-board] DB at ${dbPath} was corrupt; moved aside to ${aside}. Rebuilding from disk via fullScan.`);
|
|
91
|
+
return new Database(dbPath);
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Initializes the SQLite database and creates tables if they don't exist.
|
|
99
|
+
* @returns {import('better-sqlite3').Database}
|
|
100
|
+
*/
|
|
101
|
+
export function initDb() {
|
|
102
|
+
const kjHome = getKjHome();
|
|
103
|
+
mkdirSync(kjHome, { recursive: true });
|
|
104
|
+
|
|
105
|
+
const dbPath = join(kjHome, 'hu-board.db');
|
|
106
|
+
db = openDbResilient(dbPath);
|
|
107
|
+
|
|
108
|
+
db.pragma('journal_mode = WAL');
|
|
109
|
+
db.pragma('foreign_keys = ON');
|
|
110
|
+
// Wait up to 5s for a competing writer (e.g. concurrent fullScan +
|
|
111
|
+
// mutation) before giving up. Without this any contention surfaced
|
|
112
|
+
// as SQLITE_BUSY and crashed the request mid-flight.
|
|
113
|
+
db.pragma('busy_timeout = 5000');
|
|
114
|
+
|
|
115
|
+
// Schema-version guard. If the DB was written by a newer Karajan with
|
|
116
|
+
// a different schema, refuse to open instead of silently mis-binding
|
|
117
|
+
// named parameters against a renamed column.
|
|
118
|
+
const onDisk = db.pragma('user_version', { simple: true });
|
|
119
|
+
if (onDisk > SCHEMA_VERSION) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[hu-board] DB schema is from a newer Karajan (user_version=${onDisk}, this build expects ${SCHEMA_VERSION}). `
|
|
122
|
+
+ `Upgrade karajan-code or remove ${dbPath} (it will be rebuilt from disk).`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (onDisk < SCHEMA_VERSION) {
|
|
126
|
+
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
db.exec(`
|
|
130
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
131
|
+
id TEXT PRIMARY KEY,
|
|
132
|
+
name TEXT,
|
|
133
|
+
first_seen TEXT,
|
|
134
|
+
last_activity TEXT,
|
|
135
|
+
total_stories INTEGER DEFAULT 0
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS stories (
|
|
139
|
+
id TEXT PRIMARY KEY,
|
|
140
|
+
project_id TEXT,
|
|
141
|
+
session_id TEXT,
|
|
142
|
+
status TEXT DEFAULT 'pending',
|
|
143
|
+
title TEXT,
|
|
144
|
+
original_text TEXT,
|
|
145
|
+
certified_as TEXT,
|
|
146
|
+
certified_want TEXT,
|
|
147
|
+
certified_so_that TEXT,
|
|
148
|
+
quality_total INTEGER,
|
|
149
|
+
quality_d1 INTEGER,
|
|
150
|
+
quality_d2 INTEGER,
|
|
151
|
+
quality_d3 INTEGER,
|
|
152
|
+
quality_d4 INTEGER,
|
|
153
|
+
quality_d5 INTEGER,
|
|
154
|
+
quality_d6 INTEGER,
|
|
155
|
+
antipatterns TEXT,
|
|
156
|
+
ac_format TEXT,
|
|
157
|
+
acceptance_criteria TEXT,
|
|
158
|
+
created_at TEXT,
|
|
159
|
+
updated_at TEXT,
|
|
160
|
+
certified_at TEXT
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
164
|
+
id TEXT PRIMARY KEY,
|
|
165
|
+
project_id TEXT,
|
|
166
|
+
task TEXT,
|
|
167
|
+
status TEXT,
|
|
168
|
+
created_at TEXT,
|
|
169
|
+
updated_at TEXT,
|
|
170
|
+
iterations INTEGER DEFAULT 0,
|
|
171
|
+
duration_ms INTEGER,
|
|
172
|
+
approved INTEGER DEFAULT 0,
|
|
173
|
+
commits TEXT,
|
|
174
|
+
stages_completed TEXT,
|
|
175
|
+
checkpoints TEXT,
|
|
176
|
+
llm_calls TEXT,
|
|
177
|
+
config_snapshot TEXT,
|
|
178
|
+
budget TEXT
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE TABLE IF NOT EXISTS context_requests (
|
|
182
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
183
|
+
story_id TEXT,
|
|
184
|
+
fields_needed TEXT,
|
|
185
|
+
question TEXT,
|
|
186
|
+
answer TEXT,
|
|
187
|
+
requested_at TEXT,
|
|
188
|
+
answered_at TEXT
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
-- KJC-TSK-0380 — Tombstones: "deleted, do not resurrect".
|
|
192
|
+
-- The board's data is reconstructed from the filesystem on every
|
|
193
|
+
-- fullScan(), so a plain DELETE from the table is silently undone
|
|
194
|
+
-- the next time chokidar fires. The tombstone table holds the ids
|
|
195
|
+
-- the user explicitly killed; sync* functions check it before any
|
|
196
|
+
-- upsert and skip + clean fs paths.
|
|
197
|
+
CREATE TABLE IF NOT EXISTS tombstones (
|
|
198
|
+
resource_type TEXT NOT NULL,
|
|
199
|
+
resource_id TEXT NOT NULL,
|
|
200
|
+
deleted_at TEXT NOT NULL,
|
|
201
|
+
source TEXT NOT NULL,
|
|
202
|
+
fs_paths TEXT,
|
|
203
|
+
PRIMARY KEY (resource_type, resource_id)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
CREATE INDEX IF NOT EXISTS idx_stories_project ON stories(project_id);
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_stories_status ON stories(status);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
|
|
209
|
+
CREATE INDEX IF NOT EXISTS idx_context_story ON context_requests(story_id);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_tombstones_deleted_at ON tombstones(deleted_at);
|
|
211
|
+
`);
|
|
212
|
+
|
|
213
|
+
// --- Migrations ---
|
|
214
|
+
// Each ALTER is idempotent on restart — we swallow the duplicate-column
|
|
215
|
+
// error so adding one over time doesn't force a board DB nuke.
|
|
216
|
+
//
|
|
217
|
+
// plan_id: lets the mutation endpoints locate the source-of-truth plan
|
|
218
|
+
// JSON at ~/.karajan/plans/<slug>/<planId>.json without a scan.
|
|
219
|
+
// ac_count: number of acceptance criteria, surfaced on the card.
|
|
220
|
+
// test_count: number of acceptance_tests, surfaced on the card.
|
|
221
|
+
// blocked_by: denormalised JSON array of HU ids this story waits on,
|
|
222
|
+
// so the card can render `⏳ waits for: lao-001` inline.
|
|
223
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN plan_id TEXT'); } catch { /* already migrated */ }
|
|
224
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN ac_count INTEGER'); } catch { /* already migrated */ }
|
|
225
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN test_count INTEGER'); } catch { /* already migrated */ }
|
|
226
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN blocked_by TEXT'); } catch { /* already migrated */ }
|
|
227
|
+
// acceptance_tests is the raw JSON of `hu.acceptance_tests` — the
|
|
228
|
+
// plan stores each test as either a plain string or an object with
|
|
229
|
+
// { name, description, given/when/then, ... }. The modal renders
|
|
230
|
+
// the list; the card just shows the count (test_count above).
|
|
231
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN acceptance_tests TEXT'); } catch { /* already migrated */ }
|
|
232
|
+
// plan_order is the index of the HU in `plan.hus[]` as the planner
|
|
233
|
+
// emitted it. The planner's output is already topological (it calls
|
|
234
|
+
// addHu with `blocked_by: [prevId]`), so sorting the board by this
|
|
235
|
+
// column puts roots first and dependents last — matching what
|
|
236
|
+
// `kj run --plan` will actually execute.
|
|
237
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN plan_order INTEGER'); } catch { /* already migrated */ }
|
|
238
|
+
// spec_section (v2.7.5 PR C): the SPEC.md heading this HU
|
|
239
|
+
// implements (e.g. "5.3" or "§5 Initial Scope"). Surfaces in the
|
|
240
|
+
// card + modal so the user can trace HU → spec coverage.
|
|
241
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN spec_section TEXT'); } catch { /* already migrated */ }
|
|
242
|
+
// outcome (PR3): JSON blob with what actually happened during
|
|
243
|
+
// execution — iterations, duration, commits, branch, blockers,
|
|
244
|
+
// human summary. Stamped by hu-sub-pipeline at the end of each
|
|
245
|
+
// HU and ingested by syncPlanFile so the board can show the 📄
|
|
246
|
+
// indicator and detail modal without parsing the plan JSON
|
|
247
|
+
// again. Stored as TEXT (JSON.stringify'd) for simplicity —
|
|
248
|
+
// parsed on read by the UI.
|
|
249
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN outcome TEXT'); } catch { /* already migrated */ }
|
|
250
|
+
// KJC-TSK-0394: result (pass | fail | partial | null) ortogonal al status.
|
|
251
|
+
// Diferente del campo `outcome` arriba (que es blob JSON con metadata del
|
|
252
|
+
// run). result es el enum de "último resultado" para mostrar como badge.
|
|
253
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN result TEXT'); } catch { /* already migrated */ }
|
|
254
|
+
// KJC-TSK-0406: coder_model y reviewer_model independientes por HU.
|
|
255
|
+
// Asignados por el triage según complexity (KJC-TSK-0405) y override-able
|
|
256
|
+
// desde el modal del board. coder_provider y reviewer_provider permiten
|
|
257
|
+
// cross-provider review (claude↔codex) sin tocar el config global.
|
|
258
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN coder_model TEXT'); } catch { /* already migrated */ }
|
|
259
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_model TEXT'); } catch { /* already migrated */ }
|
|
260
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN coder_provider TEXT'); } catch { /* already migrated */ }
|
|
261
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_provider TEXT'); } catch { /* already migrated */ }
|
|
262
|
+
try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
|
|
263
|
+
|
|
264
|
+
// is_test (KJC-TSK-0371 — board polish #3): per-project flag that
|
|
265
|
+
// controls the ephemeral-cleanup heuristic. NULL = follow heuristic
|
|
266
|
+
// (id under tmp_/test_/demo_/kj-test- → ephemeral after 24h);
|
|
267
|
+
// 1 = always treat as ephemeral; 0 = NEVER auto-clean (user override).
|
|
268
|
+
// The UI surfaces this as a toggle on each project card.
|
|
269
|
+
try { db.exec('ALTER TABLE projects ADD COLUMN is_test INTEGER'); } catch { /* already migrated */ }
|
|
270
|
+
|
|
271
|
+
return db;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Returns the current database instance.
|
|
276
|
+
* @returns {import('better-sqlite3').Database}
|
|
277
|
+
*/
|
|
278
|
+
export function getDb() {
|
|
279
|
+
if (!db) throw new Error('Database not initialized. Call initDb() first.');
|
|
280
|
+
return db;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Inserts or updates a project record.
|
|
285
|
+
* @param {{ id: string, name?: string, first_seen?: string, last_activity?: string, total_stories?: number }} project
|
|
286
|
+
*/
|
|
287
|
+
export function upsertProject(project) {
|
|
288
|
+
const stmt = getDb().prepare(`
|
|
289
|
+
INSERT INTO projects (id, name, first_seen, last_activity, total_stories)
|
|
290
|
+
VALUES (@id, @name, @first_seen, @last_activity, @total_stories)
|
|
291
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
292
|
+
name = COALESCE(@name, name),
|
|
293
|
+
last_activity = COALESCE(@last_activity, last_activity),
|
|
294
|
+
total_stories = COALESCE(@total_stories, total_stories)
|
|
295
|
+
`);
|
|
296
|
+
stmt.run({
|
|
297
|
+
id: project.id,
|
|
298
|
+
name: project.name || project.id,
|
|
299
|
+
first_seen: project.first_seen || new Date().toISOString(),
|
|
300
|
+
last_activity: project.last_activity || new Date().toISOString(),
|
|
301
|
+
total_stories: project.total_stories || 0,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Inserts or updates a story record.
|
|
307
|
+
* @param {object} story
|
|
308
|
+
*/
|
|
309
|
+
export function upsertStory(story) {
|
|
310
|
+
const stmt = getDb().prepare(`
|
|
311
|
+
INSERT INTO stories (
|
|
312
|
+
id, project_id, session_id, status, title, original_text,
|
|
313
|
+
certified_as, certified_want, certified_so_that,
|
|
314
|
+
quality_total, quality_d1, quality_d2, quality_d3, quality_d4, quality_d5, quality_d6,
|
|
315
|
+
antipatterns, ac_format, acceptance_criteria,
|
|
316
|
+
created_at, updated_at, certified_at, plan_id,
|
|
317
|
+
ac_count, test_count, blocked_by, acceptance_tests, plan_order, spec_section, outcome, result,
|
|
318
|
+
coder_model, reviewer_model, coder_provider, reviewer_provider
|
|
319
|
+
) VALUES (
|
|
320
|
+
@id, @project_id, @session_id, @status, @title, @original_text,
|
|
321
|
+
@certified_as, @certified_want, @certified_so_that,
|
|
322
|
+
@quality_total, @quality_d1, @quality_d2, @quality_d3, @quality_d4, @quality_d5, @quality_d6,
|
|
323
|
+
@antipatterns, @ac_format, @acceptance_criteria,
|
|
324
|
+
@created_at, @updated_at, @certified_at, @plan_id,
|
|
325
|
+
@ac_count, @test_count, @blocked_by, @acceptance_tests, @plan_order, @spec_section, @outcome, @result,
|
|
326
|
+
@coder_model, @reviewer_model, @coder_provider, @reviewer_provider
|
|
327
|
+
)
|
|
328
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
329
|
+
status = @status,
|
|
330
|
+
title = COALESCE(@title, title),
|
|
331
|
+
original_text = COALESCE(@original_text, original_text),
|
|
332
|
+
certified_as = COALESCE(@certified_as, certified_as),
|
|
333
|
+
certified_want = COALESCE(@certified_want, certified_want),
|
|
334
|
+
certified_so_that = COALESCE(@certified_so_that, certified_so_that),
|
|
335
|
+
quality_total = COALESCE(@quality_total, quality_total),
|
|
336
|
+
quality_d1 = COALESCE(@quality_d1, quality_d1),
|
|
337
|
+
quality_d2 = COALESCE(@quality_d2, quality_d2),
|
|
338
|
+
quality_d3 = COALESCE(@quality_d3, quality_d3),
|
|
339
|
+
quality_d4 = COALESCE(@quality_d4, quality_d4),
|
|
340
|
+
quality_d5 = COALESCE(@quality_d5, quality_d5),
|
|
341
|
+
quality_d6 = COALESCE(@quality_d6, quality_d6),
|
|
342
|
+
antipatterns = COALESCE(@antipatterns, antipatterns),
|
|
343
|
+
ac_format = COALESCE(@ac_format, ac_format),
|
|
344
|
+
acceptance_criteria = COALESCE(@acceptance_criteria, acceptance_criteria),
|
|
345
|
+
updated_at = @updated_at,
|
|
346
|
+
certified_at = COALESCE(@certified_at, certified_at),
|
|
347
|
+
plan_id = COALESCE(@plan_id, plan_id),
|
|
348
|
+
ac_count = COALESCE(@ac_count, ac_count),
|
|
349
|
+
test_count = COALESCE(@test_count, test_count),
|
|
350
|
+
blocked_by = COALESCE(@blocked_by, blocked_by),
|
|
351
|
+
acceptance_tests = COALESCE(@acceptance_tests, acceptance_tests),
|
|
352
|
+
plan_order = COALESCE(@plan_order, plan_order),
|
|
353
|
+
spec_section = COALESCE(@spec_section, spec_section),
|
|
354
|
+
outcome = COALESCE(@outcome, outcome),
|
|
355
|
+
result = COALESCE(@result, result),
|
|
356
|
+
coder_model = COALESCE(@coder_model, coder_model),
|
|
357
|
+
reviewer_model = COALESCE(@reviewer_model, reviewer_model),
|
|
358
|
+
coder_provider = COALESCE(@coder_provider, coder_provider),
|
|
359
|
+
reviewer_provider = COALESCE(@reviewer_provider, reviewer_provider)
|
|
360
|
+
`);
|
|
361
|
+
stmt.run({
|
|
362
|
+
id: story.id,
|
|
363
|
+
project_id: story.project_id || 'default',
|
|
364
|
+
session_id: story.session_id || null,
|
|
365
|
+
status: story.status || 'pending',
|
|
366
|
+
title: story.title || null,
|
|
367
|
+
original_text: story.original_text || null,
|
|
368
|
+
certified_as: story.certified_as || null,
|
|
369
|
+
certified_want: story.certified_want || null,
|
|
370
|
+
certified_so_that: story.certified_so_that || null,
|
|
371
|
+
quality_total: story.quality_total ?? null,
|
|
372
|
+
quality_d1: story.quality_d1 ?? null,
|
|
373
|
+
quality_d2: story.quality_d2 ?? null,
|
|
374
|
+
quality_d3: story.quality_d3 ?? null,
|
|
375
|
+
quality_d4: story.quality_d4 ?? null,
|
|
376
|
+
quality_d5: story.quality_d5 ?? null,
|
|
377
|
+
quality_d6: story.quality_d6 ?? null,
|
|
378
|
+
antipatterns: story.antipatterns || null,
|
|
379
|
+
ac_format: story.ac_format || null,
|
|
380
|
+
acceptance_criteria: story.acceptance_criteria || null,
|
|
381
|
+
created_at: story.created_at || new Date().toISOString(),
|
|
382
|
+
updated_at: story.updated_at || new Date().toISOString(),
|
|
383
|
+
certified_at: story.certified_at || null,
|
|
384
|
+
plan_id: story.plan_id || null,
|
|
385
|
+
ac_count: story.ac_count ?? null,
|
|
386
|
+
test_count: story.test_count ?? null,
|
|
387
|
+
blocked_by: story.blocked_by || null,
|
|
388
|
+
acceptance_tests: story.acceptance_tests || null,
|
|
389
|
+
plan_order: story.plan_order ?? null,
|
|
390
|
+
spec_section: story.spec_section || null,
|
|
391
|
+
outcome: story.outcome || null,
|
|
392
|
+
result: story.result || null,
|
|
393
|
+
coder_model: story.coder_model || null,
|
|
394
|
+
reviewer_model: story.reviewer_model || null,
|
|
395
|
+
coder_provider: story.coder_provider || null,
|
|
396
|
+
reviewer_provider: story.reviewer_provider || null,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Look up a story row (minimal fields used by the mutation endpoints).
|
|
402
|
+
* @param {string} storyId
|
|
403
|
+
* @returns {{ id: string, project_id: string, plan_id: string|null, status: string }|null}
|
|
404
|
+
*/
|
|
405
|
+
export function getStoryRow(storyId) {
|
|
406
|
+
const row = getDb()
|
|
407
|
+
.prepare('SELECT id, project_id, plan_id, status FROM stories WHERE id = ?')
|
|
408
|
+
.get(storyId);
|
|
409
|
+
return row || null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Update a single story's status (in-memory — the source-of-truth plan
|
|
414
|
+
* JSON is mutated separately by the caller before or after this write).
|
|
415
|
+
* @param {string} storyId
|
|
416
|
+
* @param {string} status
|
|
417
|
+
* @returns {boolean} true if a row was updated
|
|
418
|
+
*/
|
|
419
|
+
export function updateStoryStatus(storyId, status) {
|
|
420
|
+
const res = getDb()
|
|
421
|
+
.prepare('UPDATE stories SET status = ?, updated_at = ? WHERE id = ?')
|
|
422
|
+
.run(status, new Date().toISOString(), storyId);
|
|
423
|
+
return res.changes > 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* List every distinct `plan_id` stamped on the stories of a given project.
|
|
428
|
+
* Used by "mark plan ready" so the board can act on each plan of a project
|
|
429
|
+
* without making the user pick one.
|
|
430
|
+
* @param {string} projectId
|
|
431
|
+
* @returns {string[]}
|
|
432
|
+
*/
|
|
433
|
+
export function listPlanIdsForProject(projectId) {
|
|
434
|
+
return getDb()
|
|
435
|
+
.prepare(`
|
|
436
|
+
SELECT DISTINCT plan_id FROM stories
|
|
437
|
+
WHERE project_id = ? AND plan_id IS NOT NULL
|
|
438
|
+
`)
|
|
439
|
+
.all(projectId)
|
|
440
|
+
.map((r) => r.plan_id);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Inserts or updates a session record.
|
|
445
|
+
* @param {object} session
|
|
446
|
+
*/
|
|
447
|
+
export function upsertSession(session) {
|
|
448
|
+
const stmt = getDb().prepare(`
|
|
449
|
+
INSERT INTO sessions (
|
|
450
|
+
id, project_id, task, status, created_at, updated_at,
|
|
451
|
+
iterations, duration_ms, approved, commits, stages_completed,
|
|
452
|
+
checkpoints, llm_calls, config_snapshot, budget
|
|
453
|
+
) VALUES (
|
|
454
|
+
@id, @project_id, @task, @status, @created_at, @updated_at,
|
|
455
|
+
@iterations, @duration_ms, @approved, @commits, @stages_completed,
|
|
456
|
+
@checkpoints, @llm_calls, @config_snapshot, @budget
|
|
457
|
+
)
|
|
458
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
459
|
+
status = @status,
|
|
460
|
+
task = COALESCE(@task, task),
|
|
461
|
+
updated_at = @updated_at,
|
|
462
|
+
iterations = COALESCE(@iterations, iterations),
|
|
463
|
+
duration_ms = COALESCE(@duration_ms, duration_ms),
|
|
464
|
+
approved = @approved,
|
|
465
|
+
commits = COALESCE(@commits, commits),
|
|
466
|
+
stages_completed = COALESCE(@stages_completed, stages_completed),
|
|
467
|
+
checkpoints = COALESCE(@checkpoints, checkpoints),
|
|
468
|
+
llm_calls = COALESCE(@llm_calls, llm_calls),
|
|
469
|
+
config_snapshot = COALESCE(@config_snapshot, config_snapshot),
|
|
470
|
+
budget = COALESCE(@budget, budget)
|
|
471
|
+
`);
|
|
472
|
+
stmt.run({
|
|
473
|
+
id: session.id,
|
|
474
|
+
project_id: session.project_id || 'default',
|
|
475
|
+
task: session.task || null,
|
|
476
|
+
status: session.status || 'unknown',
|
|
477
|
+
created_at: session.created_at || new Date().toISOString(),
|
|
478
|
+
updated_at: session.updated_at || new Date().toISOString(),
|
|
479
|
+
iterations: session.iterations ?? 0,
|
|
480
|
+
duration_ms: session.duration_ms ?? null,
|
|
481
|
+
approved: session.approved ? 1 : 0,
|
|
482
|
+
commits: session.commits || null,
|
|
483
|
+
stages_completed: session.stages_completed || null,
|
|
484
|
+
checkpoints: session.checkpoints || null,
|
|
485
|
+
llm_calls: session.llm_calls || null,
|
|
486
|
+
config_snapshot: session.config_snapshot || null,
|
|
487
|
+
budget: session.budget || null,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Returns all projects with aggregated stats.
|
|
493
|
+
* @returns {Array<object>}
|
|
494
|
+
*/
|
|
495
|
+
export function getProjects() {
|
|
496
|
+
return getDb().prepare(`
|
|
497
|
+
SELECT p.*,
|
|
498
|
+
(SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id) AS story_count,
|
|
499
|
+
(SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id AND s.status = 'certified') AS certified_count,
|
|
500
|
+
(SELECT COUNT(*) FROM stories s WHERE s.project_id = p.id AND s.status = 'pending') AS pending_count,
|
|
501
|
+
(SELECT COUNT(*) FROM sessions ss WHERE ss.project_id = p.id) AS session_count
|
|
502
|
+
FROM projects p
|
|
503
|
+
ORDER BY p.last_activity DESC
|
|
504
|
+
`).all();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Returns stories for a specific project.
|
|
509
|
+
* @param {string} projectId
|
|
510
|
+
* @returns {Array<object>}
|
|
511
|
+
*/
|
|
512
|
+
/**
|
|
513
|
+
* Fetch all stories of a project ordered by plan_order ASC (roots first,
|
|
514
|
+
* dependents last — matches what `kj run --plan` will actually execute)
|
|
515
|
+
* and falling back to updated_at DESC for legacy rows that never had
|
|
516
|
+
* plan_order stamped.
|
|
517
|
+
*/
|
|
518
|
+
export function getStoriesByProject(projectId) {
|
|
519
|
+
return getDb().prepare(`
|
|
520
|
+
SELECT * FROM stories WHERE project_id = ?
|
|
521
|
+
ORDER BY plan_order IS NULL, plan_order ASC, updated_at DESC
|
|
522
|
+
`).all(projectId);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Returns full story detail including context requests.
|
|
527
|
+
* @param {string} storyId
|
|
528
|
+
* @returns {object | null}
|
|
529
|
+
*/
|
|
530
|
+
export function getStoryDetail(storyId) {
|
|
531
|
+
const story = getDb().prepare('SELECT * FROM stories WHERE id = ?').get(storyId);
|
|
532
|
+
if (!story) return null;
|
|
533
|
+
|
|
534
|
+
const contextRequests = getDb().prepare(
|
|
535
|
+
'SELECT * FROM context_requests WHERE story_id = ? ORDER BY requested_at DESC'
|
|
536
|
+
).all(storyId);
|
|
537
|
+
|
|
538
|
+
return { ...story, context_requests: contextRequests };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Returns sessions for a specific project.
|
|
543
|
+
*
|
|
544
|
+
* The `project_name` column is joined in so the UI can show a human
|
|
545
|
+
* label like `tmp_kj-test-4 · trivial(doc) · 08:35` instead of just
|
|
546
|
+
* the cryptic `s_2026-05-07T...` id.
|
|
547
|
+
*
|
|
548
|
+
* @param {string} projectId
|
|
549
|
+
* @returns {Array<object>}
|
|
550
|
+
*/
|
|
551
|
+
export function getSessionsByProject(projectId) {
|
|
552
|
+
return getDb().prepare(`
|
|
553
|
+
SELECT s.id, s.project_id, s.task, s.status, s.created_at, s.updated_at,
|
|
554
|
+
s.iterations, s.duration_ms, s.approved, s.stages_completed,
|
|
555
|
+
p.name AS project_name
|
|
556
|
+
FROM sessions s
|
|
557
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
558
|
+
WHERE s.project_id = ?
|
|
559
|
+
ORDER BY s.created_at DESC
|
|
560
|
+
`).all(projectId);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Returns full session detail (with the joined project_name).
|
|
565
|
+
* @param {string} sessionId
|
|
566
|
+
* @returns {object | null}
|
|
567
|
+
*/
|
|
568
|
+
export function getSessionDetail(sessionId) {
|
|
569
|
+
return getDb().prepare(`
|
|
570
|
+
SELECT s.*, p.name AS project_name
|
|
571
|
+
FROM sessions s
|
|
572
|
+
LEFT JOIN projects p ON p.id = s.project_id
|
|
573
|
+
WHERE s.id = ?
|
|
574
|
+
`).get(sessionId);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Returns global dashboard statistics.
|
|
579
|
+
* @returns {object}
|
|
580
|
+
*/
|
|
581
|
+
export function getDashboardStats() {
|
|
582
|
+
const db = getDb();
|
|
583
|
+
const totalStories = db.prepare('SELECT COUNT(*) AS count FROM stories').get().count;
|
|
584
|
+
const certifiedStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'certified'").get().count;
|
|
585
|
+
const pendingStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'pending'").get().count;
|
|
586
|
+
const doneStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'done'").get().count;
|
|
587
|
+
const needsContextStories = db.prepare("SELECT COUNT(*) AS count FROM stories WHERE status = 'needs_context'").get().count;
|
|
588
|
+
const avgQuality = db.prepare('SELECT AVG(quality_total) AS avg FROM stories WHERE quality_total IS NOT NULL').get().avg;
|
|
589
|
+
const totalSessions = db.prepare('SELECT COUNT(*) AS count FROM sessions').get().count;
|
|
590
|
+
const approvedSessions = db.prepare('SELECT COUNT(*) AS count FROM sessions WHERE approved = 1').get().count;
|
|
591
|
+
const totalProjects = db.prepare('SELECT COUNT(*) AS count FROM projects').get().count;
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
total_stories: totalStories,
|
|
595
|
+
certified_stories: certifiedStories,
|
|
596
|
+
pending_stories: pendingStories,
|
|
597
|
+
done_stories: doneStories,
|
|
598
|
+
needs_context_stories: needsContextStories,
|
|
599
|
+
avg_quality: avgQuality ? Math.round(avgQuality * 10) / 10 : null,
|
|
600
|
+
total_sessions: totalSessions,
|
|
601
|
+
approved_sessions: approvedSessions,
|
|
602
|
+
total_projects: totalProjects,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Inserts a context request for a story.
|
|
608
|
+
* @param {object} req
|
|
609
|
+
*/
|
|
610
|
+
export function insertContextRequest(req) {
|
|
611
|
+
getDb().prepare(`
|
|
612
|
+
INSERT INTO context_requests (story_id, fields_needed, question, answer, requested_at, answered_at)
|
|
613
|
+
VALUES (@story_id, @fields_needed, @question, @answer, @requested_at, @answered_at)
|
|
614
|
+
`).run({
|
|
615
|
+
story_id: req.story_id,
|
|
616
|
+
fields_needed: req.fields_needed || null,
|
|
617
|
+
question: req.question || null,
|
|
618
|
+
answer: req.answer || null,
|
|
619
|
+
requested_at: req.requested_at || new Date().toISOString(),
|
|
620
|
+
answered_at: req.answered_at || null,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Delete a project and all its stories + sessions (cascade).
|
|
626
|
+
* Returns true if the project existed.
|
|
627
|
+
*/
|
|
628
|
+
export function deleteProject(projectId) {
|
|
629
|
+
const db = getDb();
|
|
630
|
+
const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId);
|
|
631
|
+
if (!existing) return false;
|
|
632
|
+
db.transaction(() => {
|
|
633
|
+
db.prepare('DELETE FROM stories WHERE project_id = ?').run(projectId);
|
|
634
|
+
db.prepare('DELETE FROM sessions WHERE project_id = ?').run(projectId);
|
|
635
|
+
db.prepare('DELETE FROM projects WHERE id = ?').run(projectId);
|
|
636
|
+
})();
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Set or clear the `is_test` flag on a project. Drives the
|
|
642
|
+
* ephemeral-cleanup heuristic in ephemeral-cleaner.js:
|
|
643
|
+
* - 1 → always treat as ephemeral (cleanup once last_activity > TTL)
|
|
644
|
+
* - 0 → NEVER auto-clean, regardless of id pattern (user override)
|
|
645
|
+
* - null → follow the default id-pattern heuristic
|
|
646
|
+
*
|
|
647
|
+
* @param {string} projectId
|
|
648
|
+
* @param {number|null} value one of 0, 1, null
|
|
649
|
+
* @returns {boolean} true when the project existed and was updated
|
|
650
|
+
*/
|
|
651
|
+
export function setProjectIsTest(projectId, value) {
|
|
652
|
+
if (value !== 0 && value !== 1 && value !== null) {
|
|
653
|
+
throw new Error(`setProjectIsTest: value must be 0, 1, or null (got ${value})`);
|
|
654
|
+
}
|
|
655
|
+
const db = getDb();
|
|
656
|
+
const result = db
|
|
657
|
+
.prepare('UPDATE projects SET is_test = ? WHERE id = ?')
|
|
658
|
+
.run(value, projectId);
|
|
659
|
+
return result.changes > 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Delete a single story.
|
|
664
|
+
*/
|
|
665
|
+
export function deleteStory(storyId) {
|
|
666
|
+
const db = getDb();
|
|
667
|
+
const res = db.prepare('DELETE FROM stories WHERE id = ?').run(storyId);
|
|
668
|
+
return res.changes > 0;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Delete a single session.
|
|
673
|
+
*/
|
|
674
|
+
export function deleteSession(sessionId) {
|
|
675
|
+
const db = getDb();
|
|
676
|
+
const res = db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId);
|
|
677
|
+
return res.changes > 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// --- Tombstones (KJC-TSK-0380) ---------------------------------------
|
|
681
|
+
// "This (resource_type, resource_id) is dead — don't bring it back." The
|
|
682
|
+
// sync layer consults this table BEFORE every upsert and skips records
|
|
683
|
+
// whose id is buried. Restoration is explicit via removeTombstone().
|
|
684
|
+
|
|
685
|
+
export function addTombstone(resourceType, resourceId, opts = {}) {
|
|
686
|
+
getDb().prepare(`
|
|
687
|
+
INSERT OR REPLACE INTO tombstones (resource_type, resource_id, deleted_at, source, fs_paths)
|
|
688
|
+
VALUES (?, ?, ?, ?, ?)
|
|
689
|
+
`).run(
|
|
690
|
+
resourceType, resourceId,
|
|
691
|
+
new Date().toISOString(),
|
|
692
|
+
opts.source || 'api',
|
|
693
|
+
opts.fsPaths ? JSON.stringify(opts.fsPaths) : null,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function isTombstoned(resourceType, resourceId) {
|
|
698
|
+
return !!getDb().prepare(
|
|
699
|
+
'SELECT 1 FROM tombstones WHERE resource_type = ? AND resource_id = ? LIMIT 1'
|
|
700
|
+
).get(resourceType, resourceId);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Read a single tombstone row (or null). Needed so callers can compare
|
|
705
|
+
* `deleted_at` against a resource's own timestamp before deciding whether
|
|
706
|
+
* to revive it (KJC-BUG-0055).
|
|
707
|
+
*/
|
|
708
|
+
export function getTombstone(resourceType, resourceId) {
|
|
709
|
+
const row = getDb().prepare(
|
|
710
|
+
'SELECT resource_type, resource_id, deleted_at, source, fs_paths FROM tombstones WHERE resource_type = ? AND resource_id = ?'
|
|
711
|
+
).get(resourceType, resourceId);
|
|
712
|
+
if (!row) return null;
|
|
713
|
+
return { ...row, fs_paths: row.fs_paths ? JSON.parse(row.fs_paths) : [] };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function removeTombstone(resourceType, resourceId) {
|
|
717
|
+
return getDb().prepare(
|
|
718
|
+
'DELETE FROM tombstones WHERE resource_type = ? AND resource_id = ?'
|
|
719
|
+
).run(resourceType, resourceId).changes > 0;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function listTombstones() {
|
|
723
|
+
return getDb().prepare(
|
|
724
|
+
'SELECT resource_type, resource_id, deleted_at, source, fs_paths FROM tombstones ORDER BY deleted_at DESC'
|
|
725
|
+
).all().map((row) => ({
|
|
726
|
+
...row,
|
|
727
|
+
fs_paths: row.fs_paths ? JSON.parse(row.fs_paths) : [],
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Closes the database connection.
|
|
733
|
+
*/
|
|
734
|
+
export function closeDb() {
|
|
735
|
+
if (db) {
|
|
736
|
+
db.close();
|
|
737
|
+
db = null;
|
|
738
|
+
}
|
|
739
|
+
}
|