import type { Node, NodesRepository } from "@meshtastic/sdk"; import { fromBinary, toBinary } from "@bufbuild/protobuf"; import * as Protobuf from "@meshtastic/protobufs"; import { and, eq } from "drizzle-orm"; import type { SqlocalDb } from "../db.ts"; import { nodes } from "../schema/nodes.ts"; export interface SqlocalNodesRepositoryOptions { deviceId: number; } /** * Persists the snapshot of the device's NodeDB. user/position/metrics are * stored as base64-encoded protobuf bytes — round-trip safe across schema * additions because the wire shape is the source of truth. */ export class SqlocalNodesRepository implements NodesRepository { private readonly db: SqlocalDb; private readonly deviceId: number; constructor(db: SqlocalDb, options: SqlocalNodesRepositoryOptions) { this.db = db; this.deviceId = options.deviceId; } async loadAll(): Promise { const rows = await this.db .select() .from(nodes) .where(eq(nodes.deviceId, this.deviceId)); return rows.map(rowToNode); } async get(nodeNum: number): Promise { const rows = await this.db .select() .from(nodes) .where(and(eq(nodes.deviceId, this.deviceId), eq(nodes.num, nodeNum))!) .limit(1); return rows[0] ? rowToNode(rows[0]) : undefined; } async upsert(node: Node): Promise { await this.upsertBatch([node]); } async upsertBatch(input: ReadonlyArray): Promise { if (input.length === 0) return; for (const node of input) { const row = nodeToRow(this.deviceId, node); await this.db .insert(nodes) .values(row) .onConflictDoUpdate({ target: [nodes.deviceId, nodes.num], set: { lastHeard: row.lastHeard, snr: row.snr, isFavorite: row.isFavorite, isIgnored: row.isIgnored, userJson: row.userJson, positionJson: row.positionJson, metricsJson: row.metricsJson, }, }); } } async remove(nodeNum: number): Promise { await this.db .delete(nodes) .where(and(eq(nodes.deviceId, this.deviceId), eq(nodes.num, nodeNum))!); } async clear(): Promise { await this.db.delete(nodes).where(eq(nodes.deviceId, this.deviceId)); } } interface NodeRow { deviceId: number; num: number; lastHeard: number | null; snr: number | null; isFavorite: boolean; isIgnored: boolean; userJson: string | null; positionJson: string | null; metricsJson: string | null; } function rowToNode(row: NodeRow): Node { return { num: row.num, user: row.userJson ? fromBinary(Protobuf.Mesh.UserSchema, base64Decode(row.userJson)) : undefined, position: row.positionJson ? fromBinary(Protobuf.Mesh.PositionSchema, base64Decode(row.positionJson)) : undefined, deviceMetrics: row.metricsJson ? fromBinary( Protobuf.Telemetry.DeviceMetricsSchema, base64Decode(row.metricsJson), ) : undefined, lastHeard: row.lastHeard ?? undefined, snr: row.snr ?? undefined, isFavorite: row.isFavorite, isIgnored: row.isIgnored, }; } function nodeToRow(deviceId: number, node: Node): NodeRow { return { deviceId, num: node.num, lastHeard: node.lastHeard ?? null, snr: node.snr ?? null, isFavorite: node.isFavorite, isIgnored: node.isIgnored, userJson: node.user ? base64Encode(toBinary(Protobuf.Mesh.UserSchema, node.user)) : null, positionJson: node.position ? base64Encode(toBinary(Protobuf.Mesh.PositionSchema, node.position)) : null, metricsJson: node.deviceMetrics ? base64Encode( toBinary(Protobuf.Telemetry.DeviceMetricsSchema, node.deviceMetrics), ) : null, }; } function base64Encode(bytes: Uint8Array): string { let s = ""; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); return btoa(s); } function base64Decode(s: string): Uint8Array { const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }