fix(sync): adopted lexicon frequency no longer double-counts active-session lines

The remote tracker increments imm_words/imm_kanji.frequency live, so a
snapshot holding a stale ACTIVE session (skipped by the merge) has that
session's partial occurrences baked into frequency. A word new to the
local DB adopted that full total, then received the same occurrences
again when the session finalized and merged on a later sync.

Subtract active-session occurrence counts when adopting a new row; they
are re-added exactly once when the session completes. Existing rows were
already safe (isNew guard in addWordOccurrences/addKanjiOccurrences).
Verified red/green: the new two-sync regression test fails without the
subtraction (frequency 9 instead of 5).
This commit is contained in:
2026-07-12 02:18:41 -07:00
parent c9f85473bb
commit f8c10edce0
3 changed files with 110 additions and 5 deletions
+58
View File
@@ -606,3 +606,61 @@ test('createDbSnapshot produces a mergeable copy', () => {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('adopted word frequency excludes active-session counts that merge later', () => {
const { dir, localPath, remotePath } = makeDbPair();
try {
// Remote has an ended session and a stale ACTIVE one (e.g. app crashed).
// The remote tracker increments frequency live, so the word's frequency (5)
// already includes the active session's 4 occurrences even though that
// session's lines are skipped by the merge.
insertFixtureSession(remotePath, {
uuid: 'remote-ended',
videoKey: 'showb-e1',
animeTitleKey: 'showb',
startedAtMs: BASE_MS,
applyLifetime: true,
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }],
});
insertFixtureSession(remotePath, {
uuid: 'remote-active',
videoKey: 'showb-e2',
animeTitleKey: 'showb',
startedAtMs: BASE_MS + DAY_MS,
endedAtMs: null,
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 4 }],
});
const first = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(first.sessionsMerged, 1);
assert.equal(first.activeSessionsSkipped, 1);
assert.equal(first.wordsAdded, 1);
// Only the ended session's count is adopted; the active session's slice
// is re-added when that session finalizes and syncs.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
1,
);
// The remote app restarts and finalizes the stale session.
withWritableDb(remotePath, (db) => {
db.prepare(
`UPDATE imm_sessions SET ended_at_ms = ?, status = 2
WHERE session_uuid = 'remote-active'`,
).run(String(BASE_MS + DAY_MS + 1_500_000));
});
const second = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(second.sessionsMerged, 1);
assert.equal(second.sessionsAlreadyPresent, 1);
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
assert.equal(
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
?.frequency,
5,
);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});