v0.42.52.0 fix(sync): bump last_sync_at heartbeat on 0-changes sync (#2335)

D4 invariant ("never advance last_commit on partial", sync.ts comment)
preserved. last_sync_at is a monitoring signal read by doctor
sync_freshness (warn 24h / fail 72h), separate from the import-converged
bookmark. Without this heartbeat write, a cron-driven */15 sync over a
quiet vault pins last_sync_at to the last real commit, so doctor
falsely flags the source as stale for as long as the vault is quiet.

Reproduction (5 lines, no gbrain install required):
  1. Setup a fresh obsidian source + commit a single .md file
  2. gbrain sync --source obsidian   # first_sync, last_sync_at = NOW
  3. (do nothing) gbrain sync --source obsidian   # up_to_date
  4. SELECT last_sync_at FROM sources WHERE id = 'obsidian';
  5. Observed: still pinned to step 2. Expected: bumped to step 3.

Fix: in the up_to_date early-return (sync.ts line ~1786), execute a
single `UPDATE sources SET last_sync_at = now() WHERE id = $1` before
returning. The D4-protected writeSyncAnchor path is untouched.

Test: test/sync.test.ts adds a describe block that runs two
consecutive syncs against a quiet vault and asserts last_sync_at
advances while last_commit is unchanged. PGLite + executeRaw pattern
matches the existing #1970 test scaffold.

Workaround: hourly psql touch in WSL crontab documented at
https://github.com/garrytan/gbrain/issues/[link-to-issue]

Co-authored-by: lost9999 <lost9999@users.noreply.github.com>
This commit is contained in:
lost9999
2026-07-17 11:31:55 -07:00
committed by GitHub
co-authored by lost9999
parent 74bc8f8cd1
commit 3a2033e8e2
2 changed files with 97 additions and 0 deletions
+12
View File
@@ -1786,6 +1786,18 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
detachedWorkingTreeManifest.renamed.length > 0);
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) {
// v0.42.52.0 (PR #22xx): bump last_sync_at as a heartbeat on every successful
// 0-changes sync. D4 invariant ("never advance last_commit on partial") is
// preserved: last_sync_at is a monitoring signal (doctor sync_freshness
// reads it), separate from the import-converged bookmark. Without this,
// a cron-driven `*/15 sync` over a quiet vault leaves last_sync_at pinned
// to the last real commit, so doctor falsely flags the source as stale.
if (opts.sourceId) {
await engine.executeRaw(
`UPDATE sources SET last_sync_at = now() WHERE id = $1`,
[opts.sourceId],
);
}
return {
status: 'up_to_date',
fromCommit: lastCommit,
+85
View File
@@ -911,3 +911,88 @@ describe('#1970: unreachable last_commit bookmark recovery', () => {
expect(settled.status).toBe('up_to_date');
});
});
describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant preserved)', () => {
let engine: PGLiteEngine;
const repos: string[] = [];
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
afterEach(() => {
while (repos.length) {
const d = repos.pop();
if (d) rmSync(d, { recursive: true, force: true });
}
});
function personMd(title: string, body: string): string {
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
}
function mkRepo(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-heartbeat-'));
repos.push(dir);
execSync('git init', { cwd: dir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), content);
}
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
return dir;
}
const SYNC_OPTS = { noPull: true, noEmbed: true, noExtract: true, sourceId: 'default' } as const;
async function lastSyncAt(): Promise<string | null> {
const rows = await engine.executeRaw<{ last_sync_at: string | null }>(
`SELECT last_sync_at FROM sources WHERE id = 'default'`,
);
return rows[0]?.last_sync_at ?? null;
}
test('consecutive 0-changes syncs advance last_sync_at without advancing last_commit', async () => {
const { performSync } = await import('../src/commands/sync.ts');
const repo = mkRepo({
'people/alice.md': personMd('Alice', 'Alice is a person.'),
});
const first = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(first.status).toBe('first_sync');
const afterFirst = await lastSyncAt();
expect(afterFirst).not.toBeNull();
const firstRows = await engine.executeRaw<{ last_commit: string | null }>(
`SELECT last_commit FROM sources WHERE id = 'default'`,
);
const lastCommit = firstRows[0]?.last_commit;
expect(lastCommit).not.toBeNull();
// Wait 1.1s so the DB clock will tick past `afterFirst`.
await new Promise((r) => setTimeout(r, 1100));
const second = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
expect(second.status).toBe('up_to_date');
const afterSecond = await lastSyncAt();
expect(afterSecond).not.toBeNull();
expect(afterSecond).not.toEqual(afterFirst); // heartbeat bumped
// D4 invariant: last_commit is unchanged on 0-changes sync.
const lastCommitRows = await engine.executeRaw<{ last_commit: string | null }>(
`SELECT last_commit FROM sources WHERE id = 'default'`,
);
expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit);
});
});