Persist message history to SQLite with 1s poller (#15) (#19)
CI / build + unit (push) Successful in 1m4s

This commit was merged in pull request #19.
This commit is contained in:
2026-09-25 02:46:17 +02:00
parent 756086d107
commit 74f3080626
10 changed files with 337 additions and 12 deletions
+196 -1
View File
@@ -1,5 +1,6 @@
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import * as P from '@meshtastic/protobufs';
import { DatabaseSync } from 'node:sqlite';
import { describe, expect, it } from 'vitest';
import { BROADCAST_ADDR, extractFrames, frame } from './codec.js';
import {
@@ -13,7 +14,7 @@ import {
telemetryPacketFrame,
waitFor,
} from './fake-bridge.js';
import { MeshService } from './mesh.service.js';
import { initMessagesTable, MeshService } from './mesh.service.js';
// live position update: POSITION_APP packet with a serialized Position in Data.payload
function positionPacketFrame(
@@ -987,3 +988,197 @@ describe('MeshService device info', () => {
}
});
});
describe('MeshService SQLite persistence', () => {
it('persists an injected message row on poller tick (no manual flush)', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_POLL_MS = '50';
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const svc = new MeshService(db);
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
svc.injectMessage('hello db', KAMIL_NUM);
// the poller tick must pick it up — no flushHistory() call here
await waitFor(
() =>
(
db.prepare('SELECT COUNT(*) AS n FROM messages').get() as {
n: number;
}
).n === 1,
);
const row = db
.prepare('SELECT * FROM messages WHERE text = ?')
.get('hello db') as Record<string, unknown>;
expect(row.dir).toBe('rx');
expect(row.from_node).toBe(KAMIL_NUM);
expect(row.to_node).toBeNull(); // rx rows carry no destination
expect(row.channel).toBe(0);
expect(row.status).toBeNull();
} finally {
delete process.env.MESH_POLL_MS;
svc.onModuleDestroy();
bridge.close();
}
});
it('reflects the ACK status update in the database after the next tick', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_POLL_MS = '50';
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const svc = new MeshService(db);
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
const id = await svc.sendText(42, 'ack me');
expect(svc.messages()[0]?.status).toBe('pending');
bridge.sockets[0].write(routingFrame(id, P.Mesh.Routing_Error.NONE));
await waitFor(() => svc.messages()[0]?.status === 'delivered');
// the in-place status mutation must reach SQLite on the next poller tick
// (the row may not exist before the first tick — waitFor re-checks, so stay null-safe)
await waitFor(
() =>
(
db.prepare('SELECT status FROM messages WHERE id = ?').get(id) as
| {
status: string | null;
}
| undefined
)?.status === 'delivered',
);
} finally {
delete process.env.MESH_POLL_MS;
svc.onModuleDestroy();
bridge.close();
}
});
it('seeds the history buffer from a pre-populated database on startup', () => {
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const ins = db.prepare(
'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (let i = 0; i < 150; i++)
ins.run(
1000 + i,
'rx',
null,
42,
`seed-${i}`,
0,
1_700_000_000_000 + i * 1000,
null,
null,
);
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = '1'; // closed port — no real bridge needed for seeding
const svc = new MeshService(db);
try {
svc.onModuleInit();
expect(svc.messages()).toHaveLength(100); // newest 100 of the 150 rows
expect(svc.messages()[0]).toMatchObject({
id: 1149,
dir: 'rx',
from: 42,
text: 'seed-149',
channel: 0,
}); // newest first
expect(svc.messages()[99]).toMatchObject({ id: 1050, text: 'seed-50' });
expect(svc.messages()[0]).not.toHaveProperty('status'); // rx rows carry no status
} finally {
delete process.env.MESH_HOST;
delete process.env.MESH_PORT;
svc.onModuleDestroy();
}
});
it('prunes database rows beyond the retention limit (newest 1000)', () => {
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const ins = db.prepare(
'INSERT INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
);
for (let i = 0; i < 1200; i++)
ins.run(
5000 + i,
'rx',
null,
42,
`old-${i}`,
0,
1_700_000_000_000 + i * 1000,
null,
null,
);
const svc = new MeshService(db); // no onModuleInit — flushHistory() is the unit under test
try {
svc.flushHistory(); // empty buffer: upserts nothing, retention still applies
expect(
(
db.prepare('SELECT COUNT(*) AS n FROM messages').get() as {
n: number;
}
).n,
).toBe(1000);
expect(
db.prepare('SELECT text FROM messages WHERE id = ?').get(6199),
).toMatchObject({ text: 'old-1199' }); // newest kept
expect(
db.prepare('SELECT text FROM messages WHERE id = ?').get(5199),
).toBeUndefined(); // oldest 200 pruned (i=0..199)
} finally {
svc.onModuleDestroy();
}
});
it('keeps the message history across a bridge reconnect', async () => {
const bridge = await startBridge();
process.env.MESH_HOST = '127.0.0.1';
process.env.MESH_PORT = String(bridge.port);
process.env.MESH_RECONNECT_MS = '50';
const db = new DatabaseSync(':memory:');
initMessagesTable(db);
const svc = new MeshService(db);
try {
svc.onModuleInit();
await waitFor(
() => bridge.sockets.length === 1 && svc.status().connected,
);
svc.injectMessage('survives reconnect', KAMIL_NUM);
expect(svc.messages()).toHaveLength(1);
// drop the connection; bot reconnects and must keep the history (unlike channels/nodes/telemetry)
bridge.sockets[0].destroy();
await waitFor(() => bridge.sockets.length >= 2 && svc.status().connected);
expect(svc.messages()).toHaveLength(1);
expect(svc.messages()[0]).toMatchObject({
dir: 'rx',
text: 'survives reconnect',
});
} finally {
delete process.env.MESH_RECONNECT_MS;
svc.onModuleDestroy();
bridge.close();
}
});
});