memorix 1.9.2 → 1.9.3

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.
@@ -283,11 +283,14 @@ export default defineCommand({
283
283
  let activeCount = 0;
284
284
  let ranCount = 0;
285
285
  let backendName = 'unknown';
286
+ let backendReason: string | undefined;
287
+ let observationFailure: string | undefined;
286
288
  try {
287
289
  const { initObservationStore, getObservationStore } = await import('../../store/obs-store.js');
288
290
  await initObservationStore(dataDir);
289
291
  const store = getObservationStore();
290
292
  backendName = store.getBackendName();
293
+ backendReason = store.getBackendReason?.();
291
294
  const { filterReadableObservations } = await import('../../memory/visibility.js');
292
295
  projectObservations = projectId
293
296
  ? filterReadableObservations(await store.loadAll(), { projectId })
@@ -295,12 +298,30 @@ export default defineCommand({
295
298
  obsCount = projectObservations.length;
296
299
  activeCount = projectObservations.filter((o: any) => (o.status ?? 'active') === 'active').length;
297
300
  ranCount = projectObservations.filter((o: any) => /^Ran:\s/i.test(o.title ?? '')).length;
298
- } catch { /* ignore */ }
301
+ } catch (error) {
302
+ // The store could not be opened at all. Without this the counters stay
303
+ // at zero and the branch below reports "0 total" as a healthy result,
304
+ // which is indistinguishable from a project that has no observations.
305
+ observationFailure = error instanceof Error ? error.message : String(error);
306
+ }
299
307
 
300
- if (backendName === 'degraded') {
301
- lines.push(warn('Observations: SQLite unavailable — degraded (read-only, no data)'));
302
- issues.push('SQLite backend unavailable — observations cannot be read or written.');
308
+ if (observationFailure) {
309
+ lines.push(fail(`Observations: storage unavailable — ${observationFailure}`));
310
+ issues.push(`Observation store could not be opened: ${observationFailure}`);
311
+ (report.data as any) = {
312
+ backend: 'unavailable',
313
+ observations: null,
314
+ active: null,
315
+ commandLogs: null,
316
+ error: observationFailure,
317
+ };
318
+ } else if (backendName === 'degraded') {
319
+ lines.push(fail(`Backend: degraded (read-only; writes disabled${backendReason ? `; ${backendReason}` : ''})`));
320
+ lines.push(fail('Observations: unavailable — count not reported'));
321
+ issues.push('SQLite backend unavailable — observation storage is in degraded read-only mode; observations are not being persisted.');
322
+ (report.data as any) = { backend: 'degraded', ...(backendReason ? { backendReason } : {}), observations: null, active: null, commandLogs: null };
303
323
  } else {
324
+ lines.push(ok('Backend: sqlite (read-write)'));
304
325
  lines.push(ok(`Observations: ${obsCount} total, ${activeCount} active`));
305
326
  if (ranCount > 0) {
306
327
  const pct = Math.round(ranCount / obsCount * 100);
@@ -310,32 +331,68 @@ export default defineCommand({
310
331
  }
311
332
  }
312
333
  }
313
- report.data = { observations: obsCount, active: activeCount, commandLogs: ranCount };
334
+ if (!report.data) {
335
+ report.data = { backend: backendName, observations: obsCount, active: activeCount, commandLogs: ranCount };
336
+ } else if (!(report.data as any).backend) {
337
+ (report.data as any).backend = backendName;
338
+ }
314
339
 
315
340
  // Sessions — read from SQLite (canonical store)
341
+ let sessionBackend = 'unknown';
342
+ let sessionReason: string | undefined;
316
343
  try {
317
344
  const { initSessionStore, getSessionStore } = await import('../../store/session-store.js');
318
345
  await initSessionStore(dataDir);
319
346
  const sessStore = getSessionStore();
320
- const sess = await sessStore.loadAll();
321
- const sessCount = sess.length;
322
- const activeSess = sess.filter((s: any) => s.status === 'active').length;
323
- lines.push(ok(`Sessions: ${sessCount} total, ${activeSess} active`));
324
- (report.data as any).sessions = sessCount;
325
- } catch { /* ignore */ }
347
+ sessionBackend = sessStore.getBackendName();
348
+ sessionReason = sessStore.getBackendReason?.();
349
+ if (sessionBackend === 'degraded') {
350
+ lines.push(fail(`Sessions: unavailable — read-only degraded backend${sessionReason ? `; ${sessionReason}` : ''}`));
351
+ issues.push('Session storage is in degraded read-only mode.');
352
+ (report.data as any).sessions = null;
353
+ if (sessionReason) (report.data as any).sessionBackendReason = sessionReason;
354
+ } else {
355
+ const sess = await sessStore.loadAll();
356
+ const sessCount = sess.length;
357
+ const activeSess = sess.filter((s: any) => s.status === 'active').length;
358
+ lines.push(ok(`Sessions: ${sessCount} total, ${activeSess} active`));
359
+ (report.data as any).sessions = sessCount;
360
+ }
361
+ } catch (error) {
362
+ const message = error instanceof Error ? error.message : String(error);
363
+ lines.push(fail(`Sessions: unavailable (${message})`));
364
+ issues.push(`Session storage is unavailable: ${message}`);
365
+ (report.data as any).sessions = null;
366
+ }
326
367
 
327
368
  // Mini-skills — read from SQLite (canonical store)
369
+ let skillBackend = 'unknown';
370
+ let skillReason: string | undefined;
328
371
  try {
329
372
  const { initMiniSkillStore, getMiniSkillStore } = await import('../../store/mini-skill-store.js');
330
373
  await initMiniSkillStore(dataDir);
331
374
  const skillStore = getMiniSkillStore();
332
- const skills = await skillStore.loadAll();
333
- const skillCount = skills.length;
334
- if (skillCount > 0) {
335
- lines.push(ok(`Mini-skills: ${skillCount}`));
336
- (report.data as any).miniSkills = skillCount;
375
+ skillBackend = skillStore.getBackendName();
376
+ skillReason = skillStore.getBackendReason?.();
377
+ if (skillBackend === 'degraded') {
378
+ lines.push(fail(`Mini-skills: unavailable — read-only degraded backend${skillReason ? `; ${skillReason}` : ''}`));
379
+ issues.push('Mini-skill storage is in degraded read-only mode.');
380
+ (report.data as any).miniSkills = null;
381
+ if (skillReason) (report.data as any).miniSkillBackendReason = skillReason;
382
+ } else {
383
+ const skills = await skillStore.loadAll();
384
+ const skillCount = skills.length;
385
+ if (skillCount > 0) {
386
+ lines.push(ok(`Mini-skills: ${skillCount}`));
387
+ (report.data as any).miniSkills = skillCount;
388
+ }
337
389
  }
338
- } catch { /* ignore */ }
390
+ } catch (error) {
391
+ const message = error instanceof Error ? error.message : String(error);
392
+ lines.push(fail(`Mini-skills: unavailable (${message})`));
393
+ issues.push(`Mini-skill storage is unavailable: ${message}`);
394
+ (report.data as any).miniSkills = null;
395
+ }
339
396
  } else {
340
397
  lines.push(warn('No data directory found for current project'));
341
398
  if (!projectId) {
@@ -633,5 +690,9 @@ export default defineCommand({
633
690
  } else {
634
691
  console.log(lines.join('\n'));
635
692
  }
693
+
694
+ if (issues.some((issue) => /^(Observation store could not be opened|Observation storage |Session storage |Mini-skill storage |SQLite backend unavailable)/.test(issue))) {
695
+ process.exitCode = 1;
696
+ }
636
697
  },
637
698
  });
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Backends:
5
5
  * - MiniSkillSqliteStore — canonical store, uses shared DB handle from sqlite-db.ts
6
- * - MiniSkillGracefulDegrade — no-op fallback when SQLite is unavailable
6
+ * - MiniSkillGracefulDegrade — explicit read-only fallback when SQLite is unavailable
7
7
  *
8
8
  * Phase 2 debt-zero: SQLite is the only canonical store for mini-skills.
9
9
  * JSON files are migration source only. No writable JSON fallback exists.
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { MiniSkill } from '../types.js';
13
13
  import { getDatabase } from './sqlite-db.js';
14
+ import { degradedReadError, degradedWriteError, initializeSqliteStore, isSqliteUnavailableError, retrySqliteBusy } from './sqlite-reliability.js';
14
15
  import {
15
16
  loadMiniSkillsJson,
16
17
  } from './persistence.js';
@@ -36,6 +37,7 @@ export interface MiniSkillStore {
36
37
  ensureFresh(): Promise<boolean>;
37
38
  getGeneration(): number;
38
39
  getBackendName(): 'sqlite' | 'degraded';
40
+ getBackendReason?(): string | undefined;
39
41
  }
40
42
 
41
43
  // ── Row <-> MiniSkill serialization ─────────────────────────────────
@@ -220,7 +222,7 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
220
222
  * SQLite serializes write transactions, so concurrent calls are safely sequenced.
221
223
  */
222
224
  async atomicInsertWithId(skillWithoutId: Omit<MiniSkill, 'id'>): Promise<MiniSkill> {
223
- const result = this.db.transaction(() => {
225
+ return retrySqliteBusy(async () => this.db.transaction(() => {
224
226
  // 1. Read current counter inside the transaction
225
227
  const row = this.stmtGetMeta.get('mini_skills_next_id');
226
228
  const nextId = row ? parseInt(row.value, 10) : 1;
@@ -239,8 +241,7 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
239
241
  this.knownGeneration = this.readGeneration();
240
242
 
241
243
  return skill;
242
- })();
243
- return result;
244
+ })(), 'Mini-skill promotion');
244
245
  }
245
246
 
246
247
  // ── Freshness ────────────────────────────────────────────────────
@@ -266,16 +267,21 @@ export class MiniSkillSqliteStore implements MiniSkillStore {
266
267
  // ── Graceful Degrade Fallback ────────────────────────────────────────
267
268
  //
268
269
  // Phase 2 debt-zero rule: mini-skills have NO writable JSON fallback.
269
- // In JSON-only environments (no better-sqlite3), reads return empty
270
- // and writes are no-ops with a warning. This prevents a parallel
271
- // canonical JSON write path from existing alongside SQLite.
270
+ // In environments without a supported SQLite runtime, reads return an
271
+ // explicit unavailable error and writes fail rather than pretending success.
272
272
 
273
273
  export class MiniSkillGracefulDegrade implements MiniSkillStore {
274
274
  private warned = false;
275
275
 
276
+ constructor(private readonly reason = 'SQLite runtime unavailable') {}
277
+
278
+ private unavailableRead(): Error {
279
+ return degradedReadError('mini-skills');
280
+ }
281
+
276
282
  private warn(): void {
277
283
  if (!this.warned) {
278
- console.error('[memorix] MiniSkillStore: SQLite unavailable — mini-skills are disabled (read-only empty). Install better-sqlite3 for full functionality.');
284
+ console.error('[memorix] MiniSkillStore: SQLite unavailable — mini-skills cannot be read or written. Install a supported SQLite runtime.');
279
285
  this.warned = true;
280
286
  }
281
287
  }
@@ -284,22 +290,23 @@ export class MiniSkillGracefulDegrade implements MiniSkillStore {
284
290
  this.warn();
285
291
  }
286
292
 
287
- async loadAll(): Promise<MiniSkill[]> { return []; }
288
- async loadByProject(_projectId: string): Promise<MiniSkill[]> { return []; }
289
- async loadIdCounter(): Promise<number> { return 1; }
293
+ async loadAll(): Promise<MiniSkill[]> { throw this.unavailableRead(); }
294
+ async loadByProject(_projectId: string): Promise<MiniSkill[]> { throw this.unavailableRead(); }
295
+ async loadIdCounter(): Promise<number> { throw this.unavailableRead(); }
290
296
 
291
- async insert(_skill: MiniSkill): Promise<void> { this.warn(); }
292
- async update(_skill: MiniSkill): Promise<void> { this.warn(); }
293
- async remove(_id: number): Promise<void> { this.warn(); }
294
- async saveIdCounter(_nextId: number): Promise<void> { /* no-op */ }
297
+ async insert(_skill: MiniSkill): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
298
+ async update(_skill: MiniSkill): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
299
+ async remove(_id: number): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
300
+ async saveIdCounter(_nextId: number): Promise<void> { this.warn(); throw degradedWriteError('mini-skills'); }
295
301
  async atomicInsertWithId(skillWithoutId: Omit<MiniSkill, 'id'>): Promise<MiniSkill> {
296
302
  this.warn();
297
- return { ...skillWithoutId, id: 0 } as MiniSkill;
303
+ throw degradedWriteError('mini-skills');
298
304
  }
299
305
 
300
306
  async ensureFresh(): Promise<boolean> { return false; }
301
307
  getGeneration(): number { return 0; }
302
308
  getBackendName(): 'sqlite' | 'degraded' { return 'degraded'; }
309
+ getBackendReason(): string { return this.reason; }
303
310
  }
304
311
 
305
312
  // ── Singleton access ────────────────────────────────────────────────
@@ -329,19 +336,21 @@ export async function initMiniSkillStore(dataDir: string): Promise<MiniSkillStor
329
336
  _store = null;
330
337
  _storeDataDir = null;
331
338
 
332
- // Try SQLite first
339
+ // Only a missing SQLite runtime may use the intentional read-only fallback.
340
+ let degradedReason = 'SQLite runtime unavailable';
333
341
  try {
334
- const store = new MiniSkillSqliteStore();
335
- await store.init(dataDir);
342
+ const store = await initializeSqliteStore(() => new MiniSkillSqliteStore(), dataDir, 'Mini-skill store');
336
343
  _store = store;
337
344
  _storeDataDir = dataDir;
338
345
  return store;
339
346
  } catch (err) {
340
- console.error(`[memorix] MiniSkillSqliteStore unavailable, running in degraded read-only mode: ${err instanceof Error ? err.message : err}`);
347
+ if (!isSqliteUnavailableError(err)) throw err;
348
+ degradedReason = err instanceof Error ? err.message : String(err);
349
+ console.error(`[memorix] MiniSkillSqliteStore unavailable, running in degraded read-only mode: ${degradedReason}`);
341
350
  }
342
351
 
343
- // Fallback: graceful degrade (no writable JSON backend per debt-zero rule)
344
- const store = new MiniSkillGracefulDegrade();
352
+ // Fallback: explicit read-only mode (no writable JSON backend per debt-zero rule)
353
+ const store = new MiniSkillGracefulDegrade(degradedReason);
345
354
  await store.init(dataDir);
346
355
  _store = store;
347
356
  _storeDataDir = dataDir;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Backends:
5
5
  * - SqliteBackend (sqlite-store.ts) — WAL-mode SQLite with generation tracking
6
- * - DegradedBackend — read-only empty store when SQLite is unavailable
6
+ * - DegradedBackend — diagnostic-only backend when SQLite is unavailable
7
7
  *
8
8
  * JSON is no longer a runtime writable backend.
9
9
  * observations.json is only used as a one-time migration source into SQLite.
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Observation } from '../types.js';
15
+ import { degradedReadError, degradedWriteError, formatSqliteFailure, isSqliteUnavailableError, initializeSqliteStore } from './sqlite-reliability.js';
15
16
 
16
17
  /**
17
18
  * Raw transaction handle for compound atomic operations.
@@ -164,6 +165,9 @@ export interface ObservationStore {
164
165
 
165
166
  /** Which backend is active: 'sqlite' or 'degraded' (read-only). */
166
167
  getBackendName(): 'sqlite' | 'degraded';
168
+
169
+ /** Why a degraded backend was selected, when applicable. */
170
+ getBackendReason?(): string | undefined;
167
171
  }
168
172
 
169
173
  // ── Singleton store access ─────────────────────────────────────────
@@ -199,23 +203,25 @@ export function resetObservationStore(): void {
199
203
  * without touching the process-wide singleton. This is useful for long-lived
200
204
  * multi-project hosts (for example serve-http embedded dashboard APIs) where
201
205
  * requests may need to read different project data dirs concurrently.
206
+ *
207
+ * Only a *structural* failure — better-sqlite3 genuinely unavailable — falls
208
+ * back to the read-only DegradedBackend. A transient lock is retried, and a
209
+ * persistent runtime failure is thrown rather than silently degraded: an
210
+ * empty-looking store is far more dangerous than a loud error, because
211
+ * callers cannot distinguish it from a project with no observations yet.
202
212
  */
203
213
  export async function createObservationStore(dataDir: string): Promise<ObservationStore> {
204
- // Try SQLite first (optionalDependencies — may not be installed)
205
214
  try {
206
215
  const { SqliteBackend } = await import('./sqlite-store.js');
207
- const store = new SqliteBackend();
208
- await store.init(dataDir);
209
- return store;
216
+ return await initializeSqliteStore(() => new SqliteBackend(), dataDir, 'Observation store');
210
217
  } catch (err) {
211
- console.error(`[memorix] SQLite backend unavailable — degraded mode (read-only): ${err instanceof Error ? err.message : err}`);
218
+ if (!isSqliteUnavailableError(err)) throw err;
219
+ const reason = formatSqliteFailure(err);
220
+ console.error(`[memorix] SQLite module unavailable — degraded mode (reads/writes disabled): ${reason}`);
221
+ const degraded = new DegradedBackend(reason);
222
+ await degraded.init(dataDir);
223
+ return degraded;
212
224
  }
213
-
214
- // No writable JSON fallback — degraded read-only mode instead
215
- // observations.json is only used as migration source, not runtime backend
216
- const store = new DegradedBackend();
217
- await store.init(dataDir);
218
- return store;
219
225
  }
220
226
 
221
227
  /**
@@ -243,52 +249,58 @@ export async function initObservationStore(dataDir: string): Promise<Observation
243
249
  return store;
244
250
  }
245
251
 
246
- // ── DegradedBackend (read-only when SQLite unavailable) ──────────
252
+ // ── DegradedBackend (diagnostic-only when SQLite unavailable) ─────
247
253
 
248
254
  /**
249
- * DegradedBackend — ObservationStore that is read-only and empty.
255
+ * DegradedBackend — diagnostic-only backend used when SQLite is unavailable.
250
256
  *
251
- * Used when better-sqlite3 is unavailable. All write operations throw.
257
+ * Business reads and writes throw instead of returning synthetic empty data.
252
258
  * This ensures the system does not silently fall back to writing observations.json
253
259
  * as a runtime canonical store.
254
260
  */
255
261
  export class DegradedBackend implements ObservationStore {
256
262
  private dataDir: string = '';
257
263
 
264
+ constructor(private readonly reason = 'SQLite runtime unavailable') {}
265
+
266
+ private unavailableRead(): Error {
267
+ return degradedReadError('observations');
268
+ }
269
+
258
270
  async init(dataDir: string): Promise<void> {
259
271
  this.dataDir = dataDir;
260
272
  }
261
273
 
262
274
  async loadAll(): Promise<Observation[]> {
263
- return [];
275
+ throw this.unavailableRead();
264
276
  }
265
277
 
266
278
  async loadByProject(_projectId: string, _options?: { status?: string; limit?: number; offset?: number; afterId?: number; newestFirst?: boolean }): Promise<Observation[]> {
267
- return [];
279
+ throw this.unavailableRead();
268
280
  }
269
281
 
270
282
  async countByProject(_projectId: string, _options?: { status?: string; visibility?: 'project' }): Promise<number> {
271
- return 0;
283
+ throw this.unavailableRead();
272
284
  }
273
285
 
274
286
  async getById(_id: number): Promise<Observation | undefined> {
275
- return undefined;
287
+ throw this.unavailableRead();
276
288
  }
277
289
 
278
290
  async loadIdCounter(): Promise<number> {
279
- return 1;
291
+ throw this.unavailableRead();
280
292
  }
281
293
 
282
294
  async countAll(): Promise<number> {
283
- return 0;
295
+ throw this.unavailableRead();
284
296
  }
285
297
 
286
298
  async listProjectIds(): Promise<string[]> {
287
- return [];
299
+ throw this.unavailableRead();
288
300
  }
289
301
 
290
302
  async searchLexical(_options: LexicalSearchOptions): Promise<LexicalSearchHit[]> {
291
- return [];
303
+ throw this.unavailableRead();
292
304
  }
293
305
 
294
306
  hasLexicalIndex(): boolean {
@@ -300,31 +312,31 @@ export class DegradedBackend implements ObservationStore {
300
312
  }
301
313
 
302
314
  async insert(_obs: Observation): Promise<void> {
303
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
315
+ throw degradedWriteError('observations');
304
316
  }
305
317
 
306
318
  async update(_obs: Observation): Promise<void> {
307
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
319
+ throw degradedWriteError('observations');
308
320
  }
309
321
 
310
322
  async remove(_id: number): Promise<void> {
311
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
323
+ throw degradedWriteError('observations');
312
324
  }
313
325
 
314
326
  async bulkReplace(_obs: Observation[]): Promise<void> {
315
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
327
+ throw degradedWriteError('observations');
316
328
  }
317
329
 
318
330
  async bulkRemoveByIds(_ids: number[]): Promise<void> {
319
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
331
+ throw degradedWriteError('observations');
320
332
  }
321
333
 
322
334
  async saveIdCounter(_nextId: number): Promise<void> {
323
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
335
+ throw degradedWriteError('observations');
324
336
  }
325
337
 
326
338
  async atomic<T>(_fn: (tx: StoreTransaction) => Promise<T>): Promise<T> {
327
- throw new Error('[memorix] Cannot write observations: SQLite backend unavailable (degraded mode)');
339
+ throw degradedWriteError('observations');
328
340
  }
329
341
 
330
342
  async ensureFresh(): Promise<boolean> {
@@ -342,4 +354,8 @@ export class DegradedBackend implements ObservationStore {
342
354
  getBackendName(): 'sqlite' | 'degraded' {
343
355
  return 'degraded';
344
356
  }
357
+
358
+ getBackendReason(): string {
359
+ return this.reason;
360
+ }
345
361
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Backends:
5
5
  * - SessionSqliteStore — canonical store, uses shared DB handle from sqlite-db.ts
6
- * - SessionGracefulDegrade — no-op fallback when SQLite is unavailable
6
+ * - SessionGracefulDegrade — explicit read-only fallback when SQLite is unavailable
7
7
  *
8
8
  * Phase 2 debt-zero: SQLite is the only canonical store for sessions.
9
9
  * JSON files are migration source only. No writable JSON fallback exists.
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { Session } from '../types.js';
13
13
  import { getDatabase } from './sqlite-db.js';
14
+ import { degradedReadError, degradedWriteError, formatSqliteFailure, initializeSqliteStore, isSqliteUnavailableError, retrySqliteBusy } from './sqlite-reliability.js';
14
15
  import { loadSessionsJson } from './persistence.js';
15
16
  import path from 'node:path';
16
17
  import fs from 'node:fs';
@@ -33,6 +34,7 @@ export interface SessionStoreInterface {
33
34
  */
34
35
  atomicRolloverInsert(newSession: Session, projectIds: string[], now: string): Promise<number>;
35
36
  getBackendName(): 'sqlite' | 'degraded';
37
+ getBackendReason?(): string | undefined;
36
38
  }
37
39
 
38
40
  // ── Row <-> Session serialization ───────────────────────────────────
@@ -175,7 +177,7 @@ export class SessionSqliteStore implements SessionStoreInterface {
175
177
  const stmtComplete = this._stmtCompleteActive;
176
178
  const stmtIns = this.stmtInsert;
177
179
 
178
- const result = this.db.transaction(() => {
180
+ return retrySqliteBusy(async () => this.db.transaction(() => {
179
181
  let completedCount = 0;
180
182
  for (const pid of projectIds) {
181
183
  const info = stmtComplete.run({ pid, now });
@@ -183,8 +185,7 @@ export class SessionSqliteStore implements SessionStoreInterface {
183
185
  }
184
186
  stmtIns.run(sessionToRow(newSession));
185
187
  return completedCount;
186
- })();
187
- return result;
188
+ })(), 'Session rollover');
188
189
  }
189
190
 
190
191
  getBackendName(): 'sqlite' | 'degraded' {
@@ -195,15 +196,21 @@ export class SessionSqliteStore implements SessionStoreInterface {
195
196
  // ── Graceful Degrade Fallback ────────────────────────────────────────
196
197
  //
197
198
  // Phase 2 debt-zero rule: sessions have NO writable JSON fallback.
198
- // In JSON-only environments (no better-sqlite3), reads return empty
199
- // and writes are no-ops with a warning.
199
+ // In environments without a supported SQLite runtime, reads return an
200
+ // explicit unavailable error and writes fail rather than pretending success.
200
201
 
201
202
  export class SessionGracefulDegrade implements SessionStoreInterface {
202
203
  private warned = false;
203
204
 
205
+ constructor(private readonly reason = 'SQLite runtime unavailable') {}
206
+
207
+ private unavailableRead(): Error {
208
+ return degradedReadError('sessions');
209
+ }
210
+
204
211
  private warn(): void {
205
212
  if (!this.warned) {
206
- console.error('[memorix] SessionStore: SQLite unavailable — sessions are disabled (read-only empty). Install better-sqlite3 for full functionality.');
213
+ console.error('[memorix] SessionStore: SQLite unavailable — sessions cannot be read or written. Install a supported SQLite runtime.');
207
214
  this.warned = true;
208
215
  }
209
216
  }
@@ -212,17 +219,22 @@ export class SessionGracefulDegrade implements SessionStoreInterface {
212
219
  this.warn();
213
220
  }
214
221
 
215
- async loadAll(): Promise<Session[]> { return []; }
216
- async getById(_id: string): Promise<Session | undefined> { return undefined; }
217
- async loadByProject(_projectId: string): Promise<Session[]> { return []; }
218
- async loadActive(_projectId: string): Promise<Session[]> { return []; }
222
+ async loadAll(): Promise<Session[]> { throw this.unavailableRead(); }
223
+ async getById(_id: string): Promise<Session | undefined> { throw this.unavailableRead(); }
224
+ async loadByProject(_projectId: string): Promise<Session[]> { throw this.unavailableRead(); }
225
+ async loadActive(_projectId: string): Promise<Session[]> { throw this.unavailableRead(); }
219
226
 
220
- async insert(_session: Session): Promise<void> { this.warn(); }
221
- async update(_session: Session): Promise<void> { this.warn(); }
222
- async bulkUpdate(_sessions: Session[]): Promise<void> { this.warn(); }
223
- async atomicRolloverInsert(_newSession: Session, _projectIds: string[], _now: string): Promise<number> { this.warn(); return 0; }
227
+ async insert(_session: Session): Promise<void> { this.warn(); throw degradedWriteError('sessions'); }
228
+ async update(_session: Session): Promise<void> { this.warn(); throw degradedWriteError('sessions'); }
229
+ async bulkUpdate(_sessions: Session[]): Promise<void> { this.warn(); throw degradedWriteError('sessions'); }
230
+ async atomicRolloverInsert(_newSession: Session, _projectIds: string[], _now: string): Promise<number> {
231
+ this.warn();
232
+ throw degradedWriteError('sessions');
233
+ }
224
234
 
225
235
  getBackendName(): 'sqlite' | 'degraded' { return 'degraded'; }
236
+
237
+ getBackendReason(): string { return this.reason; }
226
238
  }
227
239
 
228
240
  // ── Singleton access ────────────────────────────────────────────────
@@ -248,19 +260,21 @@ export async function initSessionStore(dataDir: string): Promise<SessionStoreInt
248
260
  _store = null;
249
261
  _storeDataDir = null;
250
262
 
251
- // Try SQLite first
263
+ // Only a missing SQLite runtime may use the intentional read-only fallback.
264
+ let degradedReason = 'SQLite runtime unavailable';
252
265
  try {
253
- const store = new SessionSqliteStore();
254
- await store.init(dataDir);
266
+ const store = await initializeSqliteStore(() => new SessionSqliteStore(), dataDir, 'Session store');
255
267
  _store = store;
256
268
  _storeDataDir = dataDir;
257
269
  return store;
258
270
  } catch (err) {
259
- console.error(`[memorix] SessionSqliteStore unavailable, running in degraded read-only mode: ${err instanceof Error ? err.message : err}`);
271
+ if (!isSqliteUnavailableError(err)) throw err;
272
+ degradedReason = formatSqliteFailure(err);
273
+ console.error(`[memorix] SessionSqliteStore unavailable, running in degraded read-only mode: ${degradedReason}`);
260
274
  }
261
275
 
262
- // Fallback: graceful degrade (no writable JSON backend per debt-zero rule)
263
- const store = new SessionGracefulDegrade();
276
+ // Fallback: explicit read-only mode (no writable JSON backend per debt-zero rule)
277
+ const store = new SessionGracefulDegrade(degradedReason);
264
278
  await store.init(dataDir);
265
279
  _store = store;
266
280
  _storeDataDir = dataDir;
@@ -1126,12 +1126,15 @@ export function getDatabase(dataDir: string): any {
1126
1126
  fs.mkdirSync(dataDir, { recursive: true });
1127
1127
 
1128
1128
  const dbPath = path.join(dataDir, 'memorix.db');
1129
- const db = createDatabase(dbPath);
1129
+ let db: any;
1130
+ try {
1131
+ db = createDatabase(dbPath);
1130
1132
 
1131
- // WAL mode for concurrent read performance
1132
- db.pragma('journal_mode = WAL');
1133
- db.pragma('busy_timeout = 5000');
1134
- db.pragma('foreign_keys = ON');
1133
+ // Set the connection-local wait policy before the journal-mode transition.
1134
+ db.pragma('busy_timeout = 5000');
1135
+ // WAL allows readers to continue while one short writer transaction runs.
1136
+ db.pragma('journal_mode = WAL');
1137
+ db.pragma('foreign_keys = ON');
1135
1138
 
1136
1139
  // Create all tables
1137
1140
  db.exec(CREATE_OBSERVATIONS_TABLE);
@@ -1188,8 +1191,14 @@ export function getDatabase(dataDir: string): any {
1188
1191
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('next_id', '1')`).run();
1189
1192
  db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('mini_skills_generation', '0')`).run();
1190
1193
 
1191
- _dbCache.set(normalized, db);
1192
- return db;
1194
+ _dbCache.set(normalized, db);
1195
+ return db;
1196
+ } catch (error) {
1197
+ // A schema/migration/FTS failure must not leave an uncached connection
1198
+ // holding a file lock while callers retry initialization.
1199
+ try { db?.close(); } catch { /* best-effort cleanup */ }
1200
+ throw error;
1201
+ }
1193
1202
  }
1194
1203
 
1195
1204
  /**