Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/t13891-one-project-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
id: t13891-one-project-snapshot
tasks: [T13891]
kind: fix
summary: "session-end and pre-op snapshots write one tasks-*.db per run for the unified project cleo.db; brain and conduit are covered by it instead of getting hard links that diverged under per-prefix retention (or full copies where links are unsupported); listBrainBackups and backup verify read the project snapshot, and older brain-*/conduit-* files stay listed"
---
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('runBackupVerify (T10319 core SDK)', () => {
expect(result.summary.corrupt).toBeGreaterThanOrEqual(1);
});

it('detects 3-DB mixed fixture: fresh+healthy / stale+healthy / corrupt', () => {
it('detects 3-DB mixed fixture: fresh+healthy / stale brain covered by the project snapshot / corrupt', () => {
// 1. Fresh + healthy: tasks
const now = new Date();
const stampNow = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
Expand All @@ -241,14 +241,15 @@ describe('runBackupVerify (T10319 core SDK)', () => {
});

expect(result.dbs['tasks']?.verdict).toBe('healthy');
expect(result.dbs['brain']?.verdict).toBe('stale');
// T13891: the fresh tasks-* snapshot is the whole project store, so it is
// brain's freshest snapshot too; the 48h brain-* copy no longer decides.
expect(result.dbs['brain']?.verdict).toBe('healthy');
expect(result.dbs['conduit']?.verdict).toBe('corrupt');

// Summary must add up across all roles.
const { healthy, stale, corrupt, missing } = result.summary;
expect(healthy + stale + corrupt + missing).toBe(DB_INVENTORY.length);
expect(healthy).toBeGreaterThanOrEqual(1);
expect(stale).toBeGreaterThanOrEqual(1);
expect(healthy).toBeGreaterThanOrEqual(2);
expect(corrupt).toBeGreaterThanOrEqual(1);
});

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/store/__tests__/backup-store-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ describe('backups restore onto the live cleo.db (T13245)', () => {
expect(readFileSync(decoy, 'utf8')).toBe('decoy');
});

it.each([
'brain',
'conduit',
])('backup recover %s picks a newer tasks-* project snapshot over an older %s-* file (T13897)', async (role) => {
// An older role-prefixed file (pre-T13891), then the store moves on and a
// run writes the one unified project snapshot as tasks-*.
const older = sessionSnapshot(role, '20261005-090000');
await seedTasks(env.accessor, [
{ id: 'T003', title: 'after the older snapshot', type: 'task' },
]);
const newer = sessionSnapshot('tasks', '20261005-100000');
const backedUp = fingerprint(newer);
expect(backedUp).not.toEqual(fingerprint(older));
await seedTasks(env.accessor, [
{ id: 'T004', title: 'after the newer snapshot', type: 'task' },
]);
resetDbState();

const result = await recoverProjectStore({ role, projectRoot: env.tempDir, cwd: env.tempDir });
expect(result.restored).toBe(true);
expect(result.source.path).toBe(newer);
expect(fingerprint(live)).toEqual(backedUp);
});

it('a corrupt newest snapshot is skipped for the next valid one', async () => {
const older = sessionSnapshot('tasks', '20261005-090000');
const backedUp = fingerprint(older);
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/store/__tests__/recover-brain-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,32 @@ describe('recoverMalformedBrainDb (T10303)', () => {
expect(result.observationsRecovered).toBe(9);
});

it('picks a newer tasks-* project snapshot over an older brain-* one (T13897)', () => {
const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-tasks-prefix-'));
const cleoDir = join(root, '.cleo');
const vacuumDir = join(cleoDir, 'backups', 'sqlite');
const snapshotDir = join(cleoDir, 'backups', 'snapshot');
const corruptPath = join(cleoDir, 'brain.db');
mkdirSync(vacuumDir, { recursive: true });
mkdirSync(snapshotDir, { recursive: true });

seedHealthyBrainDb(join(vacuumDir, 'brain-20260523-120000.db'), 2);
const newer = join(vacuumDir, 'tasks-20260523-131622.db');
seedHealthyBrainDb(newer, 5);
writeCorruptFile(corruptPath);

const result = recoverMalformedBrainDb({
corruptPath,
snapshotDir,
vacuumSnapshotDir: vacuumDir,
logger: makeLogger(),
});

expect(result.integrityOK).toBe(true);
expect(result.restoredFrom).toBe(newer);
expect(result.observationsRecovered).toBe(5);
});

it('uses legacy PRE-DUP-FIX artifacts as a last-resort fallback', () => {
const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-predup-'));
const cleoDir = join(root, '.cleo');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
* 3. Invokes `vacuumIntoBackupAll({ cwd: tempDir })`.
* 4. Exits cleanly.
*
* The parent process then asserts that a `brain-YYYYMMDD-HHmmss.db` file
* landed in `<tempDir>/.cleo/backups/sqlite/`. Before the T10316 fix the
* fast-path returned `null` and snapshotOne silently skipped brain — so
* the only artifacts were `tasks-*.db` snapshots.
* The parent process then asserts the snapshot landed in
* `<tempDir>/.cleo/backups/sqlite/`. Before the T10316 fix the fast-path
* returned `null` and snapshotOne silently skipped brain. Since T13891 brain
* lives in the unified project cleo.db, so the run writes ONE `tasks-*.db`
* snapshot that holds it, and no separate `brain-*.db` copy.
*
* This test deliberately bypasses vitest mocks because the bug surfaces only
* in a real process where module-singleton caches are empty. The mock-based
Expand All @@ -35,6 +36,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { collectSnapshotCandidatesForRole } from '../recover-malformed-db.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -64,7 +66,7 @@ describe('sqlite-backup real-process (T10316)', () => {
}
});

it('produces brain-YYYYMMDD-HHmmss.db even when no caller pre-opened brain.db', () => {
it('covers brain in the one project snapshot even when no caller pre-opened brain.db (T10316, T13891)', () => {
// The compiled dist MUST exist — the test depends on the real module
// graph (no vitest module mocks). Builds run before tests via the
// monorepo workflow; a missing dist is a clear signal to rebuild.
Expand Down Expand Up @@ -121,12 +123,17 @@ describe('sqlite-backup real-process (T10316)', () => {
const brainSnapshots = files.filter((f) => /^brain-\d{8}-\d{6}\.db$/.test(f));
const tasksSnapshots = files.filter((f) => /^tasks-\d{8}-\d{6}\.db$/.test(f));

// Headline regression check: at LEAST one brain-*.db snapshot file.
// Pre-fix, this count was 0 in production despite tasks-*.db being healthy.
expect(brainSnapshots.length).toBeGreaterThanOrEqual(1);
// T13891: brain lives in the unified project cleo.db, so the run writes
// ONE project snapshot (tasks-*) that holds the brain tables, and no
// separate brain-*.db or conduit-*.db copy.
expect(tasksSnapshots).toHaveLength(1);
expect(brainSnapshots).toHaveLength(0);
expect(files.filter((f) => /^conduit-\d{8}-\d{6}\.db$/.test(f))).toHaveLength(0);

// Sanity: tasks snapshot should also exist — confirms the eager-open
// contract holds for the previously-working path too.
expect(tasksSnapshots.length).toBeGreaterThanOrEqual(1);
// Recovery finds that snapshot for brain and conduit (T13897).
for (const role of ['brain', 'conduit'] as const) {
const candidates = collectSnapshotCandidatesForRole({ role, vacuumSnapshotDir: backupDir });
expect(candidates.map((c) => c.path)).toEqual([join(backupDir, tasksSnapshots[0] ?? '')]);
}
});
});
42 changes: 24 additions & 18 deletions packages/core/src/store/__tests__/sqlite-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* @epic T4867
*/

import { chmodSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, readdirSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
Expand Down Expand Up @@ -415,7 +415,13 @@ describe('sqlite-backup', () => {
'tasks-20260102-120000.db',
'tasks-20260101-120000.db',
]);
expect(brainList.map((e) => e.name)).toEqual(['brain-20260103-120000.db']);
// T13891: brain lives in the unified store, so its listing holds the
// project snapshots (tasks-*) plus older brain-* files, newest first.
expect(brainList.map((e) => e.name)).toEqual([
'brain-20260103-120000.db',
'tasks-20260102-120000.db',
'tasks-20260101-120000.db',
]);
// T10317: every project-tier + derived inventory row now contributes a
// bucket to listSqliteBackupsAll(). Empty buckets surface as `[]`.
expect(Object.keys(all).sort()).toEqual(
Expand Down Expand Up @@ -599,11 +605,12 @@ describe('sqlite-backup', () => {
});

/**
* T12508 #2: tasks, brain and conduit share ONE cleo.db handle. A run must
* VACUUM it once and hard-link the same file under the other prefixes, so
* every `<prefix>-*.db` reader still finds a file at no extra disk cost.
* T12508 #2 / T13891: tasks, brain and conduit share ONE cleo.db handle. A
* run VACUUMs it once and writes ONE project snapshot; brain and conduit are
* covered by that file (no hard link, no copy), so a session end costs one
* copy of the store, not three under per-prefix retention.
*/
it('one VACUUM per physical database file; other prefixes are hard links (T12508)', async () => {
it('one project snapshot per run for the unified store; brain and conduit are covered by it (T12508, T13891)', async () => {
const tempDir = join(tmpdir(), `cleo-t12508-dedupe-${Date.now()}`);
const backupDir = join(tempDir, 'backups', 'sqlite');
mkdirSync(tempDir, { recursive: true });
Expand Down Expand Up @@ -639,17 +646,17 @@ describe('sqlite-backup', () => {
expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']);
expect(r?.linked).toEqual(['brain', 'conduit']);
const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-/.test(f));
expect(files).toHaveLength(3);
const inodes = new Set(files.map((f) => statSync(join(backupDir, f)).ino));
expect(inodes.size).toBe(1);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/);
});

/**
* T12508: on a filesystem without hard links (exFAT → ENOTSUP) the shared
* file is COPIED under the other prefixes instead — still one VACUUM, and
* `brain-*` / `conduit-*` still exist for restore and recovery.
* T13891: on a filesystem without hard links (exFAT → ENOTSUP) the old
* design COPIED the shared file under brain and conduit (three full copies
* per session end). Now nothing is linked or copied: one file, and
* `listBrainBackups` finds the brain tables in that project snapshot.
*/
it('falls back to a copy when hard links are unsupported (T12508)', async () => {
it('writes one project snapshot, never a copy, where hard links are unsupported (T13891)', async () => {
const tempDir = join(tmpdir(), `cleo-t12508-nolink-${Date.now()}`);
const backupDir = join(tempDir, 'backups', 'sqlite');
mkdirSync(tempDir, { recursive: true });
Expand Down Expand Up @@ -696,13 +703,12 @@ describe('sqlite-backup', () => {

expect(vacuums).toBe(1);
expect(r?.failed).toEqual([]);
expect(r?.linked).toEqual([]);
expect(r?.linked).toEqual(['brain', 'conduit']);
expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']);
const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f));
expect(files).toHaveLength(3);
// Three separate files (copies), and brain recovery finds its prefix.
expect(new Set(files.map((f) => statSync(join(backupDir, f)).ino)).size).toBe(3);
expect(brainList).toHaveLength(1);
expect(files).toHaveLength(1);
// The one project snapshot is what the brain listing returns.
expect(brainList.map((e) => e.name)).toEqual(files);
});

/**
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/store/backup-recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
recoverMalformedDb,
resolveRoleBackupDirs,
resolveRoleDbPath,
vacuumSnapshotPrefixes,
} from './recover-malformed-db.js';
import { ensureTriggerSuspendTableAtPath } from './sync/trigger-suspend-at-path.js';
import {
Expand Down Expand Up @@ -215,11 +216,13 @@ function snapshotMatchesPin(role: DbRole, candidate: SnapshotCandidate, pin: str
);
if (iso.startsWith(pin)) return true;
}
// Vacuum snapshot: `<role>-20260523-130026.db`.
const vacuumPrefix = `${role}-`;
if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) {
const tail = name.slice(vacuumPrefix.length, -3); // strip ".db"
if (tail.startsWith(pin)) return true;
// Vacuum snapshot: `<prefix>-20260523-130026.db` (`tasks-*` also holds brain and conduit, T13897).
for (const prefix of vacuumSnapshotPrefixes(role)) {
const vacuumPrefix = `${prefix}-`;
if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) {
const tail = name.slice(vacuumPrefix.length, -3); // strip ".db"
if (tail.startsWith(pin)) return true;
}
}
return false;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/store/backup-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ function buildSnapshotPatterns(role: string): readonly RegExp[] {
new RegExp(`^${safe}\\.db\\.[A-Za-z0-9_-]+-\\d{8}-\\d{6}$`),
new RegExp(`^${safe}\\.db\\.snapshot-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z$`),
// T13245: `createBackup` writes ONE `cleo.db.<backupId>` copy of the
// project store, which holds the tasks, brain and conduit tables.
...(PROJECT_STORE_LABEL_ROLES.has(role) ? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/] : []),
// project store, which holds the tasks, brain and conduit tables. T13891:
// `vacuumIntoBackupAll` likewise writes one `tasks-*.db` for all three.
...(PROJECT_STORE_LABEL_ROLES.has(role)
? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/, /^tasks-\d{8}-\d{6}\.db$/]
: []),
];
}

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/store/recover-brain-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,11 @@ const SNAPSHOT_FILENAME_RE = /^brain\.db\.snapshot-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2

/**
* Snapshot filename pattern for VACUUM INTO debounced session-end snapshots:
* `brain-20260523-130026.db`. Format mirrors `sqlite-backup.ts:formatTimestamp`.
* `brain-20260523-130026.db`, and `tasks-*.db`: brain lives in the unified
* project store, whose one snapshot per run carries the tasks prefix
* (T13891, T13897). Format mirrors `sqlite-backup.ts:formatTimestamp`.
*/
const VACUUM_FILENAME_RE = /^brain-(\d{8})-(\d{6})\.db$/;
const VACUUM_FILENAME_RE = /^(?:brain|tasks)-(\d{8})-(\d{6})\.db$/;

/** Legacy pre-T9685 dup-fix backup pattern: `brain.db.PRE-DUP-FIX-191315`. */
const PRE_DUP_FIX_RE = /^brain\.db\.PRE-DUP-FIX-/;
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/store/recover-malformed-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,29 @@ function buildSystemSnapshotRegex(role: DbRole): RegExp {
);
}

/**
* VACUUM INTO filename prefixes that hold a role's data. Brain and conduit
* live in the unified project `cleo.db`, whose one snapshot per run is
* `tasks-*.db` (T13891); older runs also wrote `brain-*` / `conduit-*`.
*
* @param role - Inventory role.
* @returns The prefixes whose `<prefix>-YYYYMMDD-HHmmss.db` files hold it.
* @task T13897
*/
export function vacuumSnapshotPrefixes(role: DbRole): readonly string[] {
return role === 'brain' || role === 'conduit' ? [role, 'tasks'] : [role];
}

/**
* Build the per-role VACUUM-INTO snapshot filename regex (matches
* `<role>-20260523-130026.db`).
* `<prefix>-20260523-130026.db` for every {@link vacuumSnapshotPrefixes}
* prefix, so the freshest is picked across them).
*
* @internal
*/
function buildVacuumSnapshotRegex(role: DbRole): RegExp {
return new RegExp(`^${escapeRegExpLiteral(role)}-(\\d{8})-(\\d{6})\\.db$`);
const prefixes = vacuumSnapshotPrefixes(role).map(escapeRegExpLiteral).join('|');
return new RegExp(`^(?:${prefixes})-(\\d{8})-(\\d{6})\\.db$`);
}

/**
Expand Down
Loading
Loading