@hegemonart/get-design-done 1.56.0 → 1.57.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.
@@ -0,0 +1,345 @@
1
+ 'use strict';
2
+ /**
3
+ * scripts/lib/state/state-backend.cjs - Phase 57 SQLite State Backbone.
4
+ *
5
+ * Backend probe and database helpers. Mirrors the probe pattern from
6
+ * scripts/lib/instinct-store.cjs (Phase 51) and scripts/lib/design-search.cjs:
7
+ * 1. probeOptional('better-sqlite3') -> Database class or null
8
+ * 2. In-memory CREATE VIRTUAL TABLE _p USING fts5(t) probe -> _sqliteOk flag
9
+ * 3. BACKEND = 'sqlite' when both succeed, 'markdown' otherwise
10
+ * 4. GDD_STATE_BACKEND=markdown env override forces the markdown floor (tests use this)
11
+ *
12
+ * Exports: { Database, BACKEND, openStateDb, openQueryDb, checkIntegrity, sqlitePath, loadSchema }
13
+ *
14
+ * R4/R9/R10 compliance:
15
+ * - R4: better-sqlite3 only (no node:sqlite - no FTS5 support in official Node 22/24 builds)
16
+ * - R9: WAL journal + synchronous=NORMAL + busy_timeout=5000 + foreign_keys=ON
17
+ * - R10: openQueryDb uses engine-level readonly:true (engine rejects writes with SQLITE_READONLY)
18
+ *
19
+ * Package-root resolution: walk-up from __dirname to find package.json with
20
+ * name === '@hegemonart/get-design-done', then resolve sdk/state/schema.sql
21
+ * from that root. This is the Phase 53 lesson - never use __dirname-relative
22
+ * cross-tree jumps; esbuild rewrites __dirname so fixed relative paths break.
23
+ *
24
+ * NEVER throws on a missing better-sqlite3 module. Always degrades to markdown floor.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ const { probeOptional } = require('../probe-optional.cjs');
31
+ const { resolveRepoRoot } = require('../worktree-resolve.cjs');
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // better-sqlite3 + FTS5 backend probe (evaluated once at module load).
35
+ // Mirrors instinct-store.cjs and design-search.cjs backend selection exactly.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** The Database constructor from better-sqlite3, or null if unavailable. */
39
+ const Database = probeOptional('better-sqlite3');
40
+
41
+ /**
42
+ * True when better-sqlite3 is present AND its FTS5 extension is compiled in.
43
+ * The in-memory probe matches instinct-store.cjs lines 101-110 verbatim.
44
+ */
45
+ let _sqliteOk = false;
46
+ if (Database) {
47
+ try {
48
+ const probe = new Database(':memory:');
49
+ probe.exec('CREATE VIRTUAL TABLE _p USING fts5(t)');
50
+ probe.close();
51
+ _sqliteOk = true;
52
+ } catch {
53
+ /* fts5 extension not compiled in - fall back to markdown floor */
54
+ }
55
+ }
56
+
57
+ /**
58
+ * GDD_STATE_BACKEND=markdown forces the markdown floor regardless of whether
59
+ * better-sqlite3 is present. Tests use this to exercise the markdown path
60
+ * even in environments where the native module is installed.
61
+ */
62
+ const _envForceMarkdown = process.env.GDD_STATE_BACKEND === 'markdown';
63
+
64
+ /**
65
+ * 'sqlite' when better-sqlite3 + FTS5 is available AND not overridden by env.
66
+ * 'markdown' otherwise (the guaranteed fallback / CI surface).
67
+ */
68
+ const BACKEND = (!_envForceMarkdown && _sqliteOk) ? 'sqlite' : 'markdown';
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Package-root walk-up (Phase 53 lesson: never use __dirname-relative jumps).
72
+ // Walk up from __dirname to find the GDD package root, then resolve schema.sql.
73
+ // Memoized per process.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** @type {string|null} */
77
+ let _cachedPkgRoot = null;
78
+
79
+ /**
80
+ * Find the GDD package root by walking up from startDir.
81
+ * Looks for package.json with name === '@hegemonart/get-design-done'.
82
+ * Falls back to the first directory with any package.json, then startDir.
83
+ *
84
+ * @param {string} startDir
85
+ * @returns {string}
86
+ */
87
+ function _findPackageRoot(startDir) {
88
+ let dir = path.resolve(startDir);
89
+ let firstWithPkg = null;
90
+ for (let i = 0; i < 12; i++) {
91
+ const pkgPath = path.join(dir, 'package.json');
92
+ let pkg = null;
93
+ try {
94
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
95
+ } catch {
96
+ pkg = null;
97
+ }
98
+ if (pkg) {
99
+ if (firstWithPkg === null) firstWithPkg = dir;
100
+ if (pkg.name === '@hegemonart/get-design-done') return dir;
101
+ }
102
+ const parent = path.dirname(dir);
103
+ if (parent === dir) break;
104
+ dir = parent;
105
+ }
106
+ return firstWithPkg || path.resolve(startDir);
107
+ }
108
+
109
+ /**
110
+ * Resolved GDD package root, memoized. Computed from __dirname so it is
111
+ * correct regardless of the caller's cwd (and survives esbuild bundling where
112
+ * __dirname is rewritten to the bundle output location).
113
+ *
114
+ * @returns {string}
115
+ */
116
+ function _packageRoot() {
117
+ if (_cachedPkgRoot === null) _cachedPkgRoot = _findPackageRoot(__dirname);
118
+ return _cachedPkgRoot;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Schema loading
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /** @type {string|null} Memoized schema SQL (base section only). */
126
+ let _baseSchemaSql = null;
127
+ /** @type {string|null} Memoized FTS5 section SQL. */
128
+ let _fts5SchemaSql = null;
129
+
130
+ /**
131
+ * Read sdk/state/schema.sql from the package root and split into two sections:
132
+ * - base: everything before GDD_FTS5_SECTION_START
133
+ * - fts5: everything between GDD_FTS5_SECTION_START and GDD_FTS5_SECTION_END
134
+ *
135
+ * Memoized; file is read once per process.
136
+ *
137
+ * @returns {{ base: string, fts5: string }}
138
+ */
139
+ function _readSchemaSql() {
140
+ if (_baseSchemaSql !== null) return { base: _baseSchemaSql, fts5: _fts5SchemaSql || '' };
141
+
142
+ const schemaPath = path.join(_packageRoot(), 'sdk', 'state', 'schema.sql');
143
+ const raw = fs.readFileSync(schemaPath, 'utf8');
144
+
145
+ const startMarker = '-- GDD_FTS5_SECTION_START';
146
+ const endMarker = '-- GDD_FTS5_SECTION_END';
147
+ const startIdx = raw.indexOf(startMarker);
148
+ const endIdx = raw.indexOf(endMarker);
149
+
150
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
151
+ _baseSchemaSql = raw.slice(0, startIdx).trim();
152
+ _fts5SchemaSql = raw.slice(startIdx + startMarker.length, endIdx).trim();
153
+ } else {
154
+ // No FTS5 section markers found - treat the whole file as base schema.
155
+ _baseSchemaSql = raw;
156
+ _fts5SchemaSql = '';
157
+ }
158
+
159
+ return { base: _baseSchemaSql, fts5: _fts5SchemaSql };
160
+ }
161
+
162
+ /**
163
+ * Execute schema.sql against the given database, creating all base tables.
164
+ * FTS5 virtual tables are created only when _sqliteOk (and not env-overridden).
165
+ *
166
+ * Safe to call on both fresh and existing databases (CREATE TABLE IF NOT EXISTS).
167
+ *
168
+ * @param {import('better-sqlite3').Database} db
169
+ */
170
+ function loadSchema(db) {
171
+ const { base, fts5 } = _readSchemaSql();
172
+ db.exec(base);
173
+ // FTS5 section only when the probe confirmed FTS5 is available.
174
+ // Do NOT use BACKEND here - loadSchema is called from openStateDb before BACKEND
175
+ // matters. Use _sqliteOk directly (probe result, not env override).
176
+ if (_sqliteOk && fts5) {
177
+ try {
178
+ db.exec(fts5);
179
+ } catch {
180
+ /* FTS5 section failed - base tables are still created; FTS5 is optional */
181
+ }
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // sqlitePath - resolve the state.sqlite path in the main repo root.
187
+ // Uses worktree-resolve.cjs (Phase 49) to find .design/ in the main checkout,
188
+ // not in a throwaway worktree. Matches the pattern used by instinct-store.cjs.
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Resolve the path to state.sqlite in the main repo root's .design/ directory.
193
+ * Worktree-safe: uses resolveRepoRoot so writes land in the main checkout even
194
+ * when called from inside a linked worktree.
195
+ *
196
+ * @param {string} [projectRoot] starting directory for repo-root resolution
197
+ * (defaults to process.cwd())
198
+ * @returns {string} absolute path to <repoRoot>/.design/state.sqlite
199
+ */
200
+ function sqlitePath(projectRoot) {
201
+ const root = resolveRepoRoot(projectRoot || process.cwd());
202
+ return path.join(root, '.design', 'state.sqlite');
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // openStateDb - open a writer database with WAL pragmas (R9).
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * Open state.sqlite at dbPath for read-write access.
211
+ *
212
+ * PRAGMAs applied (R9):
213
+ * journal_mode=WAL - concurrent readers while writer is active
214
+ * synchronous=NORMAL - safe with WAL; fsync on checkpoints not every write
215
+ * busy_timeout=5000 - wait up to 5s instead of failing immediately on lock
216
+ * foreign_keys=ON - enforce FK constraints
217
+ *
218
+ * WAL is skipped when readonly=true (cannot set WAL on a readonly connection;
219
+ * the writer is responsible for enabling WAL on first open).
220
+ *
221
+ * Calls loadSchema(db) to ensure all tables exist before returning.
222
+ *
223
+ * NEVER call this when BACKEND === 'markdown'; callers must guard.
224
+ *
225
+ * @param {string} dbPath absolute path to state.sqlite
226
+ * @param {{ readonly?: boolean }} [opts]
227
+ * @returns {import('better-sqlite3').Database}
228
+ */
229
+ function openStateDb(dbPath, opts = {}) {
230
+ if (!Database) {
231
+ throw new Error('state-backend: better-sqlite3 not available (BACKEND=markdown)');
232
+ }
233
+ const readonly = opts.readonly === true;
234
+ const db = new Database(dbPath, readonly ? { readonly: true } : {});
235
+ if (!readonly) {
236
+ db.pragma('journal_mode = WAL');
237
+ db.pragma('synchronous = NORMAL');
238
+ db.pragma('busy_timeout = 5000');
239
+ db.pragma('foreign_keys = ON');
240
+ loadSchema(db);
241
+ }
242
+ return db;
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // openQueryDb - engine-level read-only connection (R10).
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Open state.sqlite at dbPath as a read-only connection.
251
+ *
252
+ * Uses engine-level readonly:true (better-sqlite3 passes SQLITE_OPEN_READONLY
253
+ * to sqlite3_open_v2). The engine rejects all write operations with
254
+ * SQLITE_READONLY - no schema execution is performed.
255
+ *
256
+ * This is the correct path for /gdd:state query (R10).
257
+ *
258
+ * @param {string} dbPath absolute path to state.sqlite
259
+ * @returns {import('better-sqlite3').Database}
260
+ */
261
+ function openQueryDb(dbPath) {
262
+ if (!Database) {
263
+ throw new Error('state-backend: better-sqlite3 not available (BACKEND=markdown)');
264
+ }
265
+ return new Database(dbPath, { readonly: true });
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // checkIntegrity - PRAGMA integrity_check (R11 boot check).
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Run PRAGMA integrity_check on the database.
274
+ * Returns true only when the result is a single row containing 'ok'.
275
+ * Any corruption, error, or unexpected result returns false.
276
+ *
277
+ * Used by the migration boot check (R11): degrade to markdown on failure.
278
+ *
279
+ * @param {import('better-sqlite3').Database} db
280
+ * @returns {boolean}
281
+ */
282
+ function checkIntegrity(db) {
283
+ try {
284
+ const rows = db.pragma('integrity_check');
285
+ // better-sqlite3 pragma() returns an array of row objects.
286
+ // integrity_check returns [{ integrity_check: 'ok' }] on success.
287
+ if (!Array.isArray(rows) || rows.length !== 1) return false;
288
+ const val = rows[0];
289
+ // The column name is 'integrity_check'.
290
+ if (typeof val === 'object' && val !== null) {
291
+ const v = val['integrity_check'];
292
+ return v === 'ok';
293
+ }
294
+ return false;
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Exports
302
+ // ---------------------------------------------------------------------------
303
+
304
+ module.exports = {
305
+ /**
306
+ * The Database constructor from better-sqlite3, or null when unavailable.
307
+ * Guards on this before calling any SQLite operation.
308
+ */
309
+ Database,
310
+
311
+ /**
312
+ * 'sqlite' when better-sqlite3+FTS5 is available and GDD_STATE_BACKEND!=markdown.
313
+ * 'markdown' otherwise (the CI surface and guaranteed fallback).
314
+ */
315
+ BACKEND,
316
+
317
+ /**
318
+ * Open a read-write database connection with WAL pragmas + schema applied.
319
+ * Throws when Database is null (callers should guard on BACKEND).
320
+ */
321
+ openStateDb,
322
+
323
+ /**
324
+ * Open a read-only database connection (engine-level readonly).
325
+ * Throws when Database is null.
326
+ */
327
+ openQueryDb,
328
+
329
+ /**
330
+ * Check database integrity via PRAGMA integrity_check.
331
+ * Returns true only for a clean 'ok' result.
332
+ */
333
+ checkIntegrity,
334
+
335
+ /**
336
+ * Resolve <repoRoot>/.design/state.sqlite (worktree-safe).
337
+ */
338
+ sqlitePath,
339
+
340
+ /**
341
+ * Execute schema.sql DDL against db (base tables always; FTS5 when _sqliteOk).
342
+ * Safe to call on existing databases (CREATE TABLE IF NOT EXISTS).
343
+ */
344
+ loadSchema,
345
+ };