Migrate Harbor state and traffic history to SQLite
This commit is contained in:
@@ -5,8 +5,20 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createActivityJournalService } from '../../dist/server/services/activityJournalService.js';
|
||||
import { openHarborStorage } from '../../dist/server/services/harborStorage.js';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { assertActivityJournalPage } from '../../dist/shared/activityJournal.js';
|
||||
|
||||
function serviceFor(t, filePath, now) {
|
||||
const storage = openHarborStorage(path.dirname(filePath));
|
||||
t.after(() => storage.close());
|
||||
return createActivityJournalService({ db: storage.db, now });
|
||||
}
|
||||
function persisted(filePath) {
|
||||
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'), { readOnly: true });
|
||||
try { return { schemaVersion: 1, events: db.prepare('SELECT value FROM journal ORDER BY sequence').all().map((row) => JSON.parse(row.value)) }; }
|
||||
finally { db.close(); }
|
||||
}
|
||||
function fixture(t) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-journal-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
@@ -24,7 +36,7 @@ const event = (dedupeKey, profileLabel = 'Home') => ({
|
||||
test('journal appends typed events, deduplicates and keeps stable newest-first cursors', (t) => {
|
||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||
const filePath = fixture(t);
|
||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
||||
const service = serviceFor(t, filePath, () => clock);
|
||||
service.append(event('refresh:1', 'One'));
|
||||
clock = new Date('2026-08-19T10:01:00.000Z');
|
||||
service.append(event('refresh:2', 'Two'));
|
||||
@@ -38,20 +50,20 @@ test('journal appends typed events, deduplicates and keeps stable newest-first c
|
||||
const older = service.page(10, first.nextCursor);
|
||||
assert.deepEqual(older.events.map(({ data }) => data.profileLabel), ['One']);
|
||||
assert.equal(first.events[0].dedupeKey, null);
|
||||
const inode = fs.statSync(filePath).ino;
|
||||
const inode = fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino;
|
||||
assert.equal(service.page(10).events.length, 3);
|
||||
assert.equal(fs.statSync(filePath).ino, inode);
|
||||
assert.equal(fs.statSync(path.join(path.dirname(filePath), 'harbor.sqlite')).ino, inode);
|
||||
assert.deepEqual(service.page(10, 'expired-cursor').events, []);
|
||||
});
|
||||
|
||||
test('journal prunes events older than 30 days and rejects unsafe payloads', (t) => {
|
||||
let clock = new Date('2026-07-01T00:00:00.000Z');
|
||||
const filePath = fixture(t);
|
||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
||||
const service = serviceFor(t, filePath, () => clock);
|
||||
service.append(event('old'));
|
||||
clock = new Date('2026-08-19T00:00:00.000Z');
|
||||
assert.deepEqual(service.page().events, []);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(filePath, 'utf8')).events, []);
|
||||
assert.deepEqual(persisted(filePath).events, []);
|
||||
service.append(event('new'));
|
||||
assert.throws(() => service.append({ ...event('unsafe'), data: { rawUrl: 'https://secret' } }), /Unsafe/);
|
||||
service.append({ ...event('ip'), data: { ...event('ip').data, host: '192.168.1.1' } });
|
||||
@@ -68,9 +80,9 @@ test('journal prunes events older than 30 days and rejects unsafe payloads', (t)
|
||||
'subscription.refreshed:user:pass',
|
||||
]) service.append({ ...event('safe'), dedupeKey });
|
||||
service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:user:pass' });
|
||||
const persisted = fs.readFileSync(filePath, 'utf8');
|
||||
assert.doesNotMatch(persisted, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
||||
assert.match(persisted, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
||||
const persistedText = JSON.stringify(persisted(filePath));
|
||||
assert.doesNotMatch(persistedText, /user:pass|192\.168\.1\.1|192\.168\.1\.7|2001:db8|token=secret/);
|
||||
assert.match(persistedText, /subscription\.refreshed:sha256:[a-f0-9]{64}/);
|
||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'https://user:pass@example.test/private?token=x' }), /dedupe/i);
|
||||
assert.throws(() => service.append({ ...event('safe'), dedupeKey: 'subscription.refreshed:private/path' }), /dedupe/i);
|
||||
});
|
||||
@@ -84,36 +96,24 @@ test('journal persists the 10,000 event cap when opening an oversized store', (t
|
||||
...event(`event:${index}`),
|
||||
}));
|
||||
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, events }));
|
||||
const service = createActivityJournalService({
|
||||
filePath,
|
||||
now: () => new Date('2026-08-19T01:00:00.000Z'),
|
||||
});
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).events.length, 10_000);
|
||||
const service = serviceFor(t, filePath, () => new Date('2026-08-19T01:00:00.000Z'));
|
||||
assert.equal(service.page(1).events.length, 1);
|
||||
assert.equal(persisted(filePath).events.length, 10_000);
|
||||
});
|
||||
|
||||
test('corrupt journal is isolated and recovery becomes a safe event', (t) => {
|
||||
test('corrupt journal blocks import and leaves the original intact', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, '{broken');
|
||||
const service = createActivityJournalService({
|
||||
filePath,
|
||||
now: () => new Date('2026-08-19T12:00:00.000Z'),
|
||||
});
|
||||
const page = service.page();
|
||||
assert.equal(page.storage.status, 'ready');
|
||||
assert.equal(page.events[0].type, 'journal.recovered');
|
||||
assert.ok(fs.readdirSync(path.dirname(filePath)).some((name) => name.includes('.corrupt-')));
|
||||
assert.throws(() => serviceFor(t, filePath), /Cannot migrate activity-journal.json/);
|
||||
assert.equal(fs.readFileSync(filePath, 'utf8'), '{broken');
|
||||
});
|
||||
|
||||
test('journal exposes a latched write failure until a later append succeeds', (t) => {
|
||||
const filePath = fixture(t);
|
||||
const service = createActivityJournalService({ filePath });
|
||||
const service = serviceFor(t, filePath);
|
||||
service.append(event('before-error'));
|
||||
const renameSync = fs.renameSync;
|
||||
fs.renameSync = (source, target) => {
|
||||
if (target === filePath) throw new Error('simulated journal write failure');
|
||||
return renameSync(source, target);
|
||||
};
|
||||
const db = new DatabaseSync(path.join(path.dirname(filePath), 'harbor.sqlite'));
|
||||
db.exec("CREATE TRIGGER reject_journal BEFORE INSERT ON journal BEGIN SELECT RAISE(ABORT, 'simulated journal failure'); END");
|
||||
try {
|
||||
assert.throws(() => service.append(event('lost')));
|
||||
const failed = service.page();
|
||||
@@ -122,7 +122,8 @@ test('journal exposes a latched write failure until a later append succeeds', (t
|
||||
assert.equal(failed.events.length, 1);
|
||||
assert.equal(failed.events[0].dedupeKey, null);
|
||||
} finally {
|
||||
fs.renameSync = renameSync;
|
||||
db.exec('DROP TRIGGER reject_journal');
|
||||
db.close();
|
||||
}
|
||||
service.append(event('recovered'));
|
||||
assert.equal(service.page().storage.status, 'ready');
|
||||
@@ -145,7 +146,7 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
||||
}],
|
||||
}));
|
||||
let clock = new Date('2026-08-19T10:00:00.000Z');
|
||||
const service = createActivityJournalService({ filePath, now: () => clock });
|
||||
const service = serviceFor(t, filePath, () => clock);
|
||||
const inputs = [
|
||||
['failover.primary_unavailable', 'primary', 'warning', 'probe-failed'],
|
||||
['failover.primary_recovered', 'primary', 'info', 'probe-recovered'],
|
||||
@@ -173,10 +174,10 @@ test('schema version 1 keeps legacy recovery and accepts per-channel health even
|
||||
]);
|
||||
assert.deepEqual(page.events.at(-1).data, { role: 'primary', reason: 'primary-recovered' });
|
||||
assert.equal(page.retentionDays, 30);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, 1);
|
||||
assert.equal(persisted(filePath).schemaVersion, 1);
|
||||
});
|
||||
test('journal page parser rejects malformed wire data and strips unknown event fields', (t) => {
|
||||
const service = createActivityJournalService({ filePath: fixture(t) });
|
||||
const service = serviceFor(t, fixture(t));
|
||||
service.append(event('wire'));
|
||||
const page = service.page();
|
||||
const parsed = assertActivityJournalPage({
|
||||
|
||||
Reference in New Issue
Block a user