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.
- package/CHANGELOG.md +11 -0
- package/dist/cli/index.js +318 -141
- package/dist/index.js +243 -120
- package/dist/maintenance-runner.js +184 -93
- package/dist/media-audio-runner.js +184 -93
- package/dist/media-video-runner.js +184 -93
- package/dist/memcode-runtime/CHANGELOG.md +11 -0
- package/dist/sdk.js +242 -119
- package/dist/vector-backfill-runner.js +184 -93
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +15 -0
- package/docs/ARCHITECTURE.md +23 -0
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/PERFORMANCE.md +50 -0
- package/docs/README.md +1 -1
- package/package.json +1 -1
- package/plugins/antigravity/memorix/plugin.json +1 -1
- package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
- package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/copilot/memorix/plugin.json +1 -1
- package/plugins/gemini/memorix/gemini-extension.json +1 -1
- package/plugins/hermes/memorix/plugin.yaml +1 -1
- package/plugins/omp/memorix/package.json +1 -1
- package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
- package/plugins/pi/memorix/package.json +1 -1
- package/server.json +2 -2
- package/src/cli/commands/doctor.ts +78 -17
- package/src/store/mini-skill-store.ts +31 -22
- package/src/store/obs-store.ts +46 -30
- package/src/store/session-store.ts +35 -21
- package/src/store/sqlite-db.ts +16 -7
- package/src/store/sqlite-reliability.ts +87 -0
- package/src/store/sqlite-store.ts +4 -1
|
@@ -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 {
|
|
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 (
|
|
301
|
-
lines.push(
|
|
302
|
-
issues.push(
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
lines.push(
|
|
336
|
-
(
|
|
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 {
|
|
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 —
|
|
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
|
-
|
|
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
|
|
270
|
-
// and writes
|
|
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
|
|
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[]> {
|
|
288
|
-
async loadByProject(_projectId: string): Promise<MiniSkill[]> {
|
|
289
|
-
async loadIdCounter(): Promise<number> {
|
|
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> {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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;
|
package/src/store/obs-store.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Backends:
|
|
5
5
|
* - SqliteBackend (sqlite-store.ts) — WAL-mode SQLite with generation tracking
|
|
6
|
-
* - DegradedBackend —
|
|
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
|
-
|
|
208
|
-
await store.init(dataDir);
|
|
209
|
-
return store;
|
|
216
|
+
return await initializeSqliteStore(() => new SqliteBackend(), dataDir, 'Observation store');
|
|
210
217
|
} catch (err) {
|
|
211
|
-
|
|
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 (
|
|
252
|
+
// ── DegradedBackend (diagnostic-only when SQLite unavailable) ─────
|
|
247
253
|
|
|
248
254
|
/**
|
|
249
|
-
* DegradedBackend —
|
|
255
|
+
* DegradedBackend — diagnostic-only backend used when SQLite is unavailable.
|
|
250
256
|
*
|
|
251
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
279
|
+
throw this.unavailableRead();
|
|
268
280
|
}
|
|
269
281
|
|
|
270
282
|
async countByProject(_projectId: string, _options?: { status?: string; visibility?: 'project' }): Promise<number> {
|
|
271
|
-
|
|
283
|
+
throw this.unavailableRead();
|
|
272
284
|
}
|
|
273
285
|
|
|
274
286
|
async getById(_id: number): Promise<Observation | undefined> {
|
|
275
|
-
|
|
287
|
+
throw this.unavailableRead();
|
|
276
288
|
}
|
|
277
289
|
|
|
278
290
|
async loadIdCounter(): Promise<number> {
|
|
279
|
-
|
|
291
|
+
throw this.unavailableRead();
|
|
280
292
|
}
|
|
281
293
|
|
|
282
294
|
async countAll(): Promise<number> {
|
|
283
|
-
|
|
295
|
+
throw this.unavailableRead();
|
|
284
296
|
}
|
|
285
297
|
|
|
286
298
|
async listProjectIds(): Promise<string[]> {
|
|
287
|
-
|
|
299
|
+
throw this.unavailableRead();
|
|
288
300
|
}
|
|
289
301
|
|
|
290
302
|
async searchLexical(_options: LexicalSearchOptions): Promise<LexicalSearchHit[]> {
|
|
291
|
-
|
|
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
|
|
315
|
+
throw degradedWriteError('observations');
|
|
304
316
|
}
|
|
305
317
|
|
|
306
318
|
async update(_obs: Observation): Promise<void> {
|
|
307
|
-
throw
|
|
319
|
+
throw degradedWriteError('observations');
|
|
308
320
|
}
|
|
309
321
|
|
|
310
322
|
async remove(_id: number): Promise<void> {
|
|
311
|
-
throw
|
|
323
|
+
throw degradedWriteError('observations');
|
|
312
324
|
}
|
|
313
325
|
|
|
314
326
|
async bulkReplace(_obs: Observation[]): Promise<void> {
|
|
315
|
-
throw
|
|
327
|
+
throw degradedWriteError('observations');
|
|
316
328
|
}
|
|
317
329
|
|
|
318
330
|
async bulkRemoveByIds(_ids: number[]): Promise<void> {
|
|
319
|
-
throw
|
|
331
|
+
throw degradedWriteError('observations');
|
|
320
332
|
}
|
|
321
333
|
|
|
322
334
|
async saveIdCounter(_nextId: number): Promise<void> {
|
|
323
|
-
throw
|
|
335
|
+
throw degradedWriteError('observations');
|
|
324
336
|
}
|
|
325
337
|
|
|
326
338
|
async atomic<T>(_fn: (tx: StoreTransaction) => Promise<T>): Promise<T> {
|
|
327
|
-
throw
|
|
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 —
|
|
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
|
-
|
|
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
|
|
199
|
-
// and writes
|
|
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
|
|
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[]> {
|
|
216
|
-
async getById(_id: string): Promise<Session | undefined> {
|
|
217
|
-
async loadByProject(_projectId: string): Promise<Session[]> {
|
|
218
|
-
async loadActive(_projectId: string): Promise<Session[]> {
|
|
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> {
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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;
|
package/src/store/sqlite-db.ts
CHANGED
|
@@ -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
|
-
|
|
1129
|
+
let db: any;
|
|
1130
|
+
try {
|
|
1131
|
+
db = createDatabase(dbPath);
|
|
1130
1132
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
/**
|