Persist message history to SQLite with 1s poller (#15) (#19)
CI / build + unit (push) Successful in 1m4s
CI / build + unit (push) Successful in 1m4s
This commit was merged in pull request #19.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user