Browse Source
* Refactor and consolitdate store imports - Created a new index file in the core stores directory to export all stores from a single module. - Updated imports to use consolidated store exports. * Remove unnecessary import * Update imports * First steps to persist nodeDB * Use named exports * Change store import after merge * Persistent nodeDB initial work * Key mishmatch warning, new serialization handler * Minor copilot changes * Add NODEDB_RETENTION_NUM * Updated tests * Refactor PKI mismatch logic * Clear persisted db on reset * Only persist on featureFlag * Mock featureFlag in tests --------- Co-authored-by: philon- <[email protected]>pull/802/head
committed by
GitHub
43 changed files with 1333 additions and 474 deletions
@ -1,13 +1,15 @@ |
|||
import { type Device, DeviceContext } from "@core/stores"; |
|||
import { CurrentDeviceContext } from "@core/stores"; |
|||
import type { ReactNode } from "react"; |
|||
|
|||
export interface DeviceWrapperProps { |
|||
children: ReactNode; |
|||
device?: Device; |
|||
deviceId: number; |
|||
} |
|||
|
|||
export const DeviceWrapper = ({ children, device }: DeviceWrapperProps) => { |
|||
export const DeviceWrapper = ({ children, deviceId }: DeviceWrapperProps) => { |
|||
return ( |
|||
<DeviceContext.Provider value={device}>{children}</DeviceContext.Provider> |
|||
<CurrentDeviceContext.Provider value={{ deviceId }}> |
|||
{children} |
|||
</CurrentDeviceContext.Provider> |
|||
); |
|||
}; |
|||
|
|||
@ -1,22 +1,45 @@ |
|||
export { useAppStore } from "@core/stores/appStore"; |
|||
import { useDeviceContext } from "@app/core/stores/utils/useDeviceContext"; |
|||
import { type Device, useDeviceStore } from "@core/stores/deviceStore"; |
|||
import { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; |
|||
|
|||
export { |
|||
CurrentDeviceContext, |
|||
type DeviceContext, |
|||
useDeviceContext, |
|||
} from "@app/core/stores/utils/useDeviceContext"; |
|||
export { useAppStore } from "@core/stores/appStore"; |
|||
export { |
|||
type Device, |
|||
DeviceContext, |
|||
useDevice, |
|||
type Page, |
|||
useDeviceStore, |
|||
type ValidConfigType, |
|||
type ValidModuleConfigType, |
|||
} from "@core/stores/deviceStore"; |
|||
|
|||
export { |
|||
MessageState, |
|||
type MessageStore, |
|||
MessageType, |
|||
useMessageStore, |
|||
useMessageStore, // TODO: Bring hook into this file
|
|||
} from "@core/stores/messageStore"; |
|||
|
|||
export { type NodeDB, useNodeDBStore } from "@core/stores/nodeDBStore"; |
|||
export { |
|||
SidebarProvider, |
|||
useSidebar, |
|||
useSidebar, // TODO: Bring hook into this file
|
|||
} from "@core/stores/sidebarStore"; |
|||
|
|||
// Define hooks to access the stores
|
|||
export const useNodeDB = (): NodeDB => { |
|||
const { deviceId } = useDeviceContext(); |
|||
const nodeDB = useNodeDBStore( |
|||
(s) => s.getNodeDB(deviceId) ?? s.addNodeDB(deviceId), |
|||
); |
|||
return nodeDB; |
|||
}; |
|||
export const useDevice = (): Device => { |
|||
const { deviceId } = useDeviceContext(); |
|||
|
|||
const device = useDeviceStore( |
|||
(s) => s.getDevice(deviceId) ?? s.addDevice(deviceId), |
|||
); |
|||
return device; |
|||
}; |
|||
|
|||
@ -0,0 +1,440 @@ |
|||
import { create } from "@bufbuild/protobuf"; |
|||
import { featureFlags } from "@core/services/featureFlags"; |
|||
import { createStorage } from "@core/stores/utils/indexDB.ts"; |
|||
import { Protobuf, type Types } from "@meshtastic/core"; |
|||
import { produce } from "immer"; |
|||
import { create as createStore, type StateCreator } from "zustand"; |
|||
import { type PersistOptions, persist } from "zustand/middleware"; |
|||
import type { NodeError, NodeErrorType, ProcessPacketParams } from "./types"; |
|||
|
|||
const CURRENT_STORE_VERSION = 0; |
|||
const NODEDB_RETENTION_NUM = 10; |
|||
|
|||
export interface NodeDB { |
|||
id: number; |
|||
myNodeNum: number | undefined; |
|||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>; |
|||
nodeErrors: Map<number, NodeError>; |
|||
|
|||
addNode: (nodeInfo: Protobuf.Mesh.NodeInfo) => void; |
|||
removeNode: (nodeNum: number) => void; |
|||
removeAllNodes: (keepMyNode?: boolean) => void; |
|||
processPacket: (data: ProcessPacketParams) => void; |
|||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void; |
|||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void; |
|||
updateFavorite: (nodeNum: number, isFavorite: boolean) => void; |
|||
updateIgnore: (nodeNum: number, isIgnored: boolean) => void; |
|||
setNodeNum: (nodeNum: number) => void; |
|||
setNodeError: (nodeNum: number, error: NodeErrorType) => void; |
|||
clearNodeError: (nodeNum: number) => void; |
|||
removeAllNodeErrors: () => void; |
|||
|
|||
getNodesLength: () => number; |
|||
getNode: (nodeNum: number) => Protobuf.Mesh.NodeInfo | undefined; |
|||
getNodes: ( |
|||
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean, |
|||
) => Protobuf.Mesh.NodeInfo[]; |
|||
getMyNode: () => Protobuf.Mesh.NodeInfo; |
|||
|
|||
getNodeError: (nodeNum: number) => NodeError | undefined; |
|||
hasNodeError: (nodeNum: number) => boolean; |
|||
} |
|||
|
|||
export interface nodeDBState { |
|||
addNodeDB: (id: number) => NodeDB; |
|||
removeNodeDB: (id: number) => void; |
|||
getNodeDBs: () => NodeDB[]; |
|||
getNodeDB: (id: number) => NodeDB | undefined; |
|||
} |
|||
|
|||
interface PrivateNodeDBState extends nodeDBState { |
|||
nodeDBs: Map<number, NodeDB>; |
|||
} |
|||
|
|||
type NodeDBData = { |
|||
id: number; |
|||
myNodeNum: number | undefined; |
|||
nodeMap: Map<number, Protobuf.Mesh.NodeInfo>; |
|||
nodeErrors: Map<number, NodeError>; |
|||
}; |
|||
|
|||
type NodeDBPersisted = { |
|||
nodeDBs: Map<number, NodeDBData>; |
|||
}; |
|||
|
|||
function nodeDBFactory( |
|||
id: number, |
|||
get: () => PrivateNodeDBState, |
|||
set: typeof useNodeDBStore.setState, |
|||
data?: Partial<NodeDBData>, |
|||
): NodeDB { |
|||
const nodeMap = data?.nodeMap ?? new Map<number, Protobuf.Mesh.NodeInfo>(); |
|||
const nodeErrors = data?.nodeErrors ?? new Map<number, NodeError>(); |
|||
const myNodeNum = data?.myNodeNum; |
|||
|
|||
return { |
|||
id, |
|||
myNodeNum, |
|||
nodeMap, |
|||
nodeErrors, |
|||
|
|||
addNode: (node) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
nodeDB.nodeMap.set(node.num, node); |
|||
}), |
|||
), |
|||
|
|||
removeNode: (nodeNum) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
nodeDB.nodeMap.delete(nodeNum); |
|||
}), |
|||
), |
|||
|
|||
removeAllNodes: (keepMyNode) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
const newNodeMap = new Map<number, Protobuf.Mesh.NodeInfo>(); |
|||
if ( |
|||
keepMyNode && |
|||
nodeDB.myNodeNum !== undefined && |
|||
nodeDB.nodeMap.has(nodeDB.myNodeNum) |
|||
) { |
|||
newNodeMap.set( |
|||
nodeDB.myNodeNum, |
|||
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? |
|||
create(Protobuf.Mesh.NodeInfoSchema), |
|||
); |
|||
} |
|||
nodeDB.nodeMap = newNodeMap; |
|||
}), |
|||
), |
|||
|
|||
setNodeError: (nodeNum, error) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
nodeDB.nodeErrors.set(nodeNum, { node: nodeNum, error }); |
|||
}), |
|||
), |
|||
|
|||
clearNodeError: (nodeNum) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
nodeDB.nodeErrors.delete(nodeNum); |
|||
}), |
|||
), |
|||
|
|||
removeAllNodeErrors: () => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
nodeDB.nodeErrors = new Map<number, NodeError>(); |
|||
}), |
|||
), |
|||
|
|||
processPacket: (data) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
const node = nodeDB.nodeMap.get(data.from); |
|||
if (node) { |
|||
node.lastHeard = data.time; |
|||
node.snr = data.snr; |
|||
nodeDB.nodeMap.set(data.from, node); |
|||
} else { |
|||
nodeDB.nodeMap.set( |
|||
data.from, |
|||
create(Protobuf.Mesh.NodeInfoSchema, { |
|||
num: data.from, |
|||
lastHeard: data.time, |
|||
snr: data.snr, |
|||
}), |
|||
); |
|||
} |
|||
}), |
|||
), |
|||
|
|||
addUser: (user) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
const current = |
|||
nodeDB.nodeMap.get(user.from) ?? |
|||
create(Protobuf.Mesh.NodeInfoSchema); |
|||
current.user = user.data; |
|||
current.num = user.from; |
|||
nodeDB.nodeMap.set(user.from, current); |
|||
}), |
|||
), |
|||
|
|||
addPosition: (position) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
const current = |
|||
nodeDB.nodeMap.get(position.from) ?? |
|||
create(Protobuf.Mesh.NodeInfoSchema); |
|||
current.position = position.data; |
|||
current.num = position.from; |
|||
nodeDB.nodeMap.set(position.from, current); |
|||
}), |
|||
), |
|||
|
|||
setNodeNum: (nodeNum) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const newDB = draft.nodeDBs.get(id); |
|||
if (!newDB) { |
|||
throw new Error(`No nodeDB found for id: ${id}`); |
|||
} |
|||
|
|||
newDB.myNodeNum = nodeNum; |
|||
|
|||
for (const [key, oldDB] of draft.nodeDBs) { |
|||
if (key === id) { |
|||
continue; |
|||
} |
|||
if (oldDB.myNodeNum === nodeNum) { |
|||
// The new DB is typically empty when nodenum is set, so we can safely copy over from the old DB
|
|||
// otherwise, discard the old DB completely
|
|||
if (newDB.nodeMap.size === 0) { |
|||
newDB.nodeMap = oldDB.nodeMap; |
|||
newDB.nodeErrors = oldDB.nodeErrors; |
|||
} else { |
|||
console.error( |
|||
`NodeDB with id: ${id} already has nodes, not merging with old DB`, |
|||
); |
|||
} |
|||
|
|||
draft.nodeDBs.delete(key); |
|||
} |
|||
} |
|||
}), |
|||
), |
|||
|
|||
updateFavorite: (nodeNum, isFavorite) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
|
|||
const node = nodeDB.nodeMap.get(nodeNum); |
|||
if (node) { |
|||
node.isFavorite = isFavorite; |
|||
} |
|||
}), |
|||
), |
|||
|
|||
updateIgnore: (nodeNum, isIgnored) => |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const nodeDB = draft.nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
|
|||
const node = nodeDB.nodeMap.get(nodeNum); |
|||
if (node) { |
|||
node.isIgnored = isIgnored; |
|||
} |
|||
}), |
|||
), |
|||
|
|||
getNodesLength: () => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
return nodeDB.nodeMap.size; |
|||
}, |
|||
|
|||
getNode: (nodeNum) => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
return nodeDB.nodeMap.get(nodeNum); |
|||
}, |
|||
|
|||
getNodes: (filter) => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
const all = Array.from(nodeDB.nodeMap.values()).filter( |
|||
(n) => n.num !== nodeDB.myNodeNum, |
|||
); |
|||
return filter ? all.filter(filter) : all; |
|||
}, |
|||
|
|||
getMyNode: () => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
if (!nodeDB.myNodeNum) { |
|||
throw new Error(`No myNodeNum set for nodeDB with id: ${id}`); |
|||
} |
|||
return ( |
|||
nodeDB.nodeMap.get(nodeDB.myNodeNum) ?? |
|||
create(Protobuf.Mesh.NodeInfoSchema) |
|||
); |
|||
}, |
|||
|
|||
getNodeError: (nodeNum) => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
return nodeDB.nodeErrors.get(nodeNum); |
|||
}, |
|||
|
|||
hasNodeError: (nodeNum) => { |
|||
const nodeDB = get().nodeDBs.get(id); |
|||
if (!nodeDB) { |
|||
throw new Error(`No nodeDB found (id: ${id})`); |
|||
} |
|||
return nodeDB.nodeErrors.has(nodeNum); |
|||
}, |
|||
}; |
|||
} |
|||
|
|||
export const nodeDBInitializer: StateCreator<PrivateNodeDBState> = ( |
|||
set, |
|||
get, |
|||
) => ({ |
|||
nodeDBs: new Map(), |
|||
|
|||
addNodeDB: (id) => { |
|||
const existing = get().nodeDBs.get(id); |
|||
if (existing) { |
|||
return existing; |
|||
} |
|||
|
|||
const nodeDB = nodeDBFactory(id, get, set); |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
draft.nodeDBs.set(id, nodeDB); |
|||
|
|||
// If over limit, remove oldest inserted. FIFO
|
|||
if (draft.nodeDBs.size > NODEDB_RETENTION_NUM) { |
|||
const firstKey = draft.nodeDBs.keys().next().value; |
|||
if (firstKey !== undefined) { |
|||
draft.nodeDBs.delete(firstKey); |
|||
} |
|||
} |
|||
}), |
|||
); |
|||
|
|||
return nodeDB; |
|||
}, |
|||
removeNodeDB: (id) => { |
|||
set( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
draft.nodeDBs.delete(id); |
|||
}), |
|||
); |
|||
}, |
|||
getNodeDBs: () => Array.from(get().nodeDBs.values()), |
|||
getNodeDB: (id) => get().nodeDBs.get(id), |
|||
}); |
|||
|
|||
const persistOptions: PersistOptions<PrivateNodeDBState, NodeDBPersisted> = { |
|||
name: "meshtastic-nodedb-store", |
|||
storage: createStorage<NodeDBPersisted>(), |
|||
version: CURRENT_STORE_VERSION, |
|||
partialize: (s): NodeDBPersisted => ({ |
|||
nodeDBs: new Map( |
|||
Array.from(s.nodeDBs.entries()).map(([id, db]) => [ |
|||
id, |
|||
{ |
|||
id: db.id, |
|||
myNodeNum: db.myNodeNum, |
|||
nodeMap: db.nodeMap, |
|||
nodeErrors: db.nodeErrors, |
|||
}, |
|||
]), |
|||
), |
|||
}), |
|||
onRehydrateStorage: () => (state) => { |
|||
if (!state) { |
|||
return; |
|||
} |
|||
console.debug( |
|||
"NodeDBStore: Rehydrating state with ", |
|||
state.nodeDBs.size, |
|||
" nodeDBs -", |
|||
state.nodeDBs, |
|||
); |
|||
|
|||
useNodeDBStore.setState( |
|||
produce<PrivateNodeDBState>((draft) => { |
|||
const rebuilt = new Map<number, NodeDB>(); |
|||
for (const [id, data] of ( |
|||
draft.nodeDBs as unknown as Map<number, NodeDBData> |
|||
).entries()) { |
|||
if (data.myNodeNum !== undefined) { |
|||
// Only rebuild if there is a nodenum set otherwise orphan dbs will acumulate
|
|||
rebuilt.set( |
|||
id, |
|||
nodeDBFactory( |
|||
id, |
|||
useNodeDBStore.getState, |
|||
useNodeDBStore.setState, |
|||
data, |
|||
), |
|||
); |
|||
} |
|||
} |
|||
draft.nodeDBs = rebuilt; |
|||
}), |
|||
); |
|||
}, |
|||
}; |
|||
|
|||
// Add persist middleware on the store if the feature flag is enabled
|
|||
const persistNodes = featureFlags.get("persistNodeDB"); |
|||
console.debug( |
|||
`NodeDBStore: Persisting nodes is ${persistNodes ? "enabled" : "disabled"}`, |
|||
); |
|||
|
|||
export const useNodeDBStore = persistNodes |
|||
? createStore<PrivateNodeDBState, [["zustand/persist", NodeDBPersisted]]>( |
|||
persist(nodeDBInitializer, persistOptions), |
|||
) |
|||
: createStore<PrivateNodeDBState>()(nodeDBInitializer); |
|||
@ -0,0 +1,30 @@ |
|||
import { vi } from "vitest"; |
|||
import type { NodeDB } from "./index.ts"; |
|||
|
|||
/** |
|||
* You can spread this base mock in your tests and override only the |
|||
* properties relevant to a specific test case. |
|||
*/ |
|||
export const mockNodeDBStore: NodeDB = { |
|||
id: 0, |
|||
myNodeNum: 0, |
|||
nodeErrors: new Map(), |
|||
nodeMap: new Map(), |
|||
|
|||
addNode: vi.fn(), |
|||
addUser: vi.fn(), |
|||
addPosition: vi.fn(), |
|||
removeNode: vi.fn(), |
|||
processPacket: vi.fn(), |
|||
setNodeError: vi.fn(), |
|||
clearNodeError: vi.fn(), |
|||
getNodeError: vi.fn().mockReturnValue(undefined), |
|||
hasNodeError: vi.fn().mockReturnValue(false), |
|||
getNodes: vi.fn().mockReturnValue([]), |
|||
getNodesLength: vi.fn().mockReturnValue(0), |
|||
getNode: vi.fn().mockReturnValue(undefined), |
|||
getMyNode: vi.fn(), |
|||
updateFavorite: vi.fn(), |
|||
updateIgnore: vi.fn(), |
|||
setNodeNum: vi.fn(), |
|||
}; |
|||
@ -0,0 +1,258 @@ |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
|
|||
const idbMem = new Map<string, string>(); |
|||
vi.mock("idb-keyval", () => ({ |
|||
get: vi.fn((key: string) => Promise.resolve(idbMem.get(key))), |
|||
set: vi.fn((key: string, val: string) => { |
|||
idbMem.set(key, val); |
|||
return Promise.resolve(); |
|||
}), |
|||
del: vi.fn((k: string) => { |
|||
idbMem.delete(k); |
|||
return Promise.resolve(); |
|||
}), |
|||
})); |
|||
|
|||
// import a fresh copy of the store module (because the store is created at import time)
|
|||
async function freshStore() { |
|||
vi.resetModules(); |
|||
const mod = await import("../nodeDBStore"); |
|||
return mod; |
|||
} |
|||
|
|||
vi.mock("@core/services/featureFlags", () => { |
|||
return { |
|||
featureFlags: { |
|||
get: vi.fn((key: string) => { |
|||
if (key === "persistNodeDB") return true; |
|||
return false; |
|||
}), |
|||
}, |
|||
}; |
|||
}); |
|||
|
|||
function makeNode(num: number, extras: Record<string, any> = {}) { |
|||
return { num, ...extras } as any; |
|||
} |
|||
|
|||
describe("NodeDB store", () => { |
|||
beforeEach(() => { |
|||
idbMem.clear(); |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
it("addNodeDB returns same instance on repeated calls; getNodeDB works", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
|
|||
const db1 = useNodeDBStore.getState().addNodeDB(123); |
|||
const db2 = useNodeDBStore.getState().addNodeDB(123); |
|||
expect(db1).toBe(db2); |
|||
|
|||
const got = useNodeDBStore.getState().getNodeDB(123); |
|||
expect(got).toBe(db1); |
|||
|
|||
expect(useNodeDBStore.getState().getNodeDBs().length).toBe(1); |
|||
}); |
|||
|
|||
it("addNode, getNode(s), getNodesLength, removeNode", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
|
|||
db.addNode(makeNode(10)); |
|||
db.addNode(makeNode(11)); |
|||
expect(db.getNodesLength()).toBe(2); |
|||
expect(db.getNode(10)?.num).toBe(10); |
|||
|
|||
const all = db.getNodes(); |
|||
expect(all.map(n => n.num).sort()).toEqual([10, 11]); |
|||
|
|||
db.removeNode(10); |
|||
expect(db.getNodesLength()).toBe(1); |
|||
expect(db.getNode(10)).toBeUndefined(); |
|||
}); |
|||
|
|||
it("processPacket creates or updates a node", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
|
|||
db.processPacket({ from: 50, time: 1111, snr: 7 } as any); |
|||
expect(db.getNode(50)).toBeTruthy(); |
|||
expect(db.getNode(50)?.lastHeard).toBe(1111); |
|||
expect(db.getNode(50)?.snr).toBe(7); |
|||
|
|||
db.processPacket({ from: 50, time: 2222, snr: 9 } as any); |
|||
expect(db.getNode(50)?.lastHeard).toBe(2222); |
|||
expect(db.getNode(50)?.snr).toBe(9); |
|||
}); |
|||
|
|||
it("addUser and addPosition updates existing or creates new nodes", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
|
|||
// addUser creates node if missing
|
|||
db.addUser({ from: 77, data: { id: "u" } } as any); |
|||
expect(db.getNode(77)?.user).toEqual({ id: "u" }); |
|||
|
|||
// addPosition updates same node
|
|||
db.addPosition({ from: 77, data: { lat: 1, lon: 2 } } as any); |
|||
expect(db.getNode(77)?.position).toEqual({ lat: 1, lon: 2 }); |
|||
expect(db.getNode(77)?.num).toBe(77); |
|||
}); |
|||
|
|||
it("errors map: setNodeError, getNodeError, hasNodeError, clearNodeError", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
|
|||
db.setNodeError(10, "BadFoo" as any); |
|||
expect(db.hasNodeError(10)).toBe(true); |
|||
expect(db.getNodeError(10)).toEqual({ node: 10, error: "BadFoo" }); |
|||
|
|||
db.clearNodeError(10); |
|||
expect(db.hasNodeError(10)).toBe(false); |
|||
expect(db.getNodeError(10)).toBeUndefined(); |
|||
}); |
|||
|
|||
it("getMyNode throws before setNodeNum; works after", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
db.addNode(makeNode(123)); |
|||
|
|||
expect(() => db.getMyNode()).toThrow(); |
|||
db.setNodeNum(123); |
|||
|
|||
const me = db.getMyNode(); |
|||
expect(me.num).toBe(123); |
|||
}); |
|||
|
|||
it("setNodeNum merges with existing DB with same myNodeNum", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
|
|||
|
|||
const oldDB = st.addNodeDB(10); |
|||
oldDB.setNodeNum(999); |
|||
oldDB.addNode(makeNode(200)); |
|||
oldDB.setNodeError(200, "ERROR" as any); |
|||
|
|||
const newDB = st.addNodeDB(11); |
|||
// newDB currently empty; setting same myNodeNum should copy maps from oldDB and delete old
|
|||
newDB.setNodeNum(999); |
|||
|
|||
expect(st.getNodeDB(10)).toBeUndefined(); |
|||
expect(st.getNodeDB(11)).toBeDefined(); |
|||
expect(newDB.getNode(200)).toBeTruthy(); |
|||
expect(newDB.getNodeError(200)).toEqual({ node: 200, error: "ERROR" }); |
|||
}); |
|||
|
|||
it("setNodeNum does not merge when new DB already has nodes; old is removed", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
|
|||
const oldDB = st.addNodeDB(10); |
|||
oldDB.setNodeNum(999); |
|||
oldDB.addNode(makeNode(200)); // old has data
|
|||
|
|||
const newDB = st.addNodeDB(11); |
|||
newDB.addNode(makeNode(300)); // new has data -> no merge
|
|||
newDB.setNodeNum(999); |
|||
|
|||
expect(st.getNodeDB(10)).toBeUndefined(); // old removed
|
|||
// new kept its own nodes; did not copy old's
|
|||
expect(newDB.getNode(300)).toBeTruthy(); |
|||
expect(newDB.getNode(200)).toBeUndefined(); |
|||
}); |
|||
|
|||
it("partialize persists only data, and onRehydrateStorage rebuilds methods", async () => { |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
const db = st.addNodeDB(123); |
|||
db.setNodeNum(321); |
|||
db.addNode(makeNode(50)); |
|||
db.setNodeError(50, "ERROR" as any); |
|||
} |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
const db = st.getNodeDB(123)!; |
|||
|
|||
// methods should work after rehydrate
|
|||
expect(db.getNode(50)?.num).toBe(50); |
|||
expect(db.getNodeError(50)).toEqual({ node: 50, error: "ERROR" }); |
|||
db.addNode(makeNode(51)); |
|||
expect(db.getNode(51)).toBeTruthy(); |
|||
} |
|||
}); |
|||
|
|||
it("getNodes applies filter and excludes myNodeNum", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const db = useNodeDBStore.getState().addNodeDB(1); |
|||
db.setNodeNum(11); |
|||
db.addNode(makeNode(10)); |
|||
db.addNode(makeNode(11)); |
|||
db.addNode(makeNode(12)); |
|||
|
|||
const all = db.getNodes(); |
|||
expect(all.map((n) => n.num).sort()).toEqual([10, 12]); // excludes my (11)
|
|||
|
|||
const filtered = db.getNodes((n) => n.num > 10); |
|||
expect(filtered.map((n) => n.num).sort()).toEqual([12]); // still excludes 11
|
|||
}); |
|||
|
|||
it("when exceeding cap, evicts earliest inserted, not the newly added", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
for (let i = 1; i <= 10; i++) st.addNodeDB(i); |
|||
st.addNodeDB(11); |
|||
expect(st.getNodeDB(1)).toBeUndefined(); |
|||
expect(st.getNodeDB(11)).toBeDefined(); |
|||
}); |
|||
|
|||
it("removeNodeDB persists removal across reload", async () => { |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
st.addNodeDB(99); |
|||
expect(st.getNodeDB(99)).toBeDefined(); |
|||
st.removeNodeDB(99); |
|||
expect(st.getNodeDB(99)).toBeUndefined(); |
|||
} |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
expect(st.getNodeDB(99)).toBeUndefined(); // still gone
|
|||
} |
|||
}); |
|||
|
|||
it("on rehydrate only rebuilds DBs with myNodeNum set (orphans dropped)", async () => { |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
|
|||
const orphan = st.addNodeDB(500); // no setNodeNum
|
|||
orphan.addNode(makeNode(1)); |
|||
|
|||
const good = st.addNodeDB(501); |
|||
good.setNodeNum(42); |
|||
good.addNode(makeNode(2)); |
|||
} |
|||
{ |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
expect(st.getNodeDB(500)).toBeUndefined(); // orphan dropped
|
|||
expect(st.getNodeDB(501)).toBeDefined(); // kept
|
|||
expect(st.getNodeDB(501)!.getNode(2)).toBeTruthy(); |
|||
} |
|||
}); |
|||
|
|||
it("methods throw after their DB is removed from the map", async () => { |
|||
const { useNodeDBStore } = await freshStore(); |
|||
const st = useNodeDBStore.getState(); |
|||
const db = st.addNodeDB(800); |
|||
|
|||
st.removeNodeDB(800); |
|||
|
|||
expect(() => db.getNodesLength()).toThrow(/No nodeDB found/); |
|||
expect(() => db.addNode(makeNode(1))).toThrow(/No nodeDB found/); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,16 @@ |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
|
|||
type NodeErrorType = Protobuf.Mesh.Routing_Error | "MISMATCH_PKI"; |
|||
|
|||
type NodeError = { |
|||
node: number; |
|||
error: NodeErrorType; |
|||
}; |
|||
|
|||
type ProcessPacketParams = { |
|||
from: number; |
|||
snr: number; |
|||
time: number; |
|||
}; |
|||
|
|||
export type { NodeError, ProcessPacketParams, NodeErrorType }; |
|||
@ -0,0 +1,158 @@ |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import * as idb from "idb-keyval"; |
|||
import { createStorage } from "./indexDB"; |
|||
|
|||
type PersistStorage<T> = ReturnType<typeof createStorage<T>>; |
|||
|
|||
describe("indexDB.ts persistence (steps 1–5)", () => { |
|||
let store: PersistStorage<any>; |
|||
|
|||
beforeEach(() => { |
|||
vi.restoreAllMocks(); |
|||
store = createStorage<any>(); |
|||
}); |
|||
|
|||
async function roundTrip(obj: any) { |
|||
const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); |
|||
const getSpy = vi.spyOn(idb, "get"); |
|||
await store.setItem("rt", obj); |
|||
const storedStr = (setSpy.mock.calls[0] as any[])[1] as string; |
|||
getSpy.mockResolvedValue(storedStr); |
|||
return await store.getItem("rt"); |
|||
} |
|||
|
|||
// Basic methods
|
|||
it("getItem returns null when idb-keyval.get yields undefined", async () => { |
|||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); |
|||
const res = await store.getItem("missing-key"); |
|||
expect(getSpy).toHaveBeenCalledWith("missing-key"); |
|||
expect(res).toBeNull(); |
|||
}); |
|||
|
|||
it("setItem writes a string via idb-keyval.set", async () => { |
|||
const setSpy = vi.spyOn(idb, "set").mockResolvedValue(); |
|||
const payload = { state: { a: 1 }, version: 0 }; |
|||
await store.setItem("k1", payload); |
|||
expect(setSpy).toHaveBeenCalledTimes(1); |
|||
const [, value] = setSpy.mock.calls[0]!; |
|||
expect(typeof value).toBe("string"); |
|||
// sanity: it should be JSON
|
|||
expect(() => JSON.parse(value as string)).not.toThrow(); |
|||
}); |
|||
|
|||
it("removeItem calls idb-keyval.del and getItem returns null afterwards", async () => { |
|||
const delSpy = vi.spyOn(idb, "del").mockResolvedValue(); |
|||
await store.removeItem("k2"); |
|||
expect(delSpy).toHaveBeenCalledWith("k2"); |
|||
|
|||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(undefined); |
|||
const res = await store.getItem("k2"); |
|||
expect(getSpy).toHaveBeenCalledWith("k2"); |
|||
expect(res).toBeNull(); |
|||
}); |
|||
|
|||
// Map
|
|||
it("round-trips an empty Map", async () => { |
|||
const out = await roundTrip({ state: { m: new Map() }, version: 0 }); |
|||
expect(out?.state.m instanceof Map).toBe(true); |
|||
expect(out?.state.m.size).toBe(0); |
|||
}); |
|||
|
|||
it("round-trips a Map<number,string> and preserves numeric key semantics", async () => { |
|||
const m = new Map<number, string>([[1, "a"]]); |
|||
const out = await roundTrip({ state: { m }, version: 0 }); |
|||
const m2 = out!.state.m as Map<number, string>; |
|||
expect(m2 instanceof Map).toBe(true); |
|||
expect(m2.get(1)).toBe("a"); |
|||
}); |
|||
|
|||
it("round-trips nested Map inside arrays/objects", async () => { |
|||
const payload = { |
|||
state: { |
|||
list: [new Map<number, number>([[2, 3]])], |
|||
obj: { inner: new Map<string, number>([["k", 7]]) }, |
|||
}, |
|||
version: 0, |
|||
}; |
|||
const out = await roundTrip(payload); |
|||
|
|||
expect(out!.state.list[0] instanceof Map).toBe(true); |
|||
expect((out!.state.list[0] as Map<number, number>).get(2)).toBe(3); |
|||
expect(out!.state.obj.inner instanceof Map).toBe(true); |
|||
expect((out!.state.obj.inner as Map<string, number>).get("k")).toBe(7); |
|||
}); |
|||
|
|||
// Uint8Array
|
|||
it("round-trips a Uint8Array (simple)", async () => { |
|||
const u8 = new Uint8Array([1, 2, 255]); |
|||
const out = await roundTrip({ state: { u8 }, version: 0 }); |
|||
const u2 = out!.state.u8 as Uint8Array; |
|||
expect(u2 instanceof Uint8Array).toBe(true); |
|||
expect(Array.from(u2)).toEqual([1, 2, 255]); |
|||
}); |
|||
|
|||
it("round-trips a Uint8Array view with non-zero byteOffset", async () => { |
|||
const buf = new Uint8Array([0, 9, 8, 7, 6, 5, 4, 3]).buffer; |
|||
const view = new Uint8Array(buf, 2, 4); // [8,7,6,5]
|
|||
const out = await roundTrip({ state: { view }, version: 0 }); |
|||
const v2 = out!.state.view as Uint8Array; |
|||
expect(v2 instanceof Uint8Array).toBe(true); |
|||
expect(Array.from(v2)).toEqual([8, 7, 6, 5]); |
|||
// ensure it's a standalone buffer now, without offset
|
|||
expect(v2.byteOffset).toBe(0); |
|||
}); |
|||
|
|||
// Mixed & nested structures
|
|||
it("round-trips Map values containing objects with nested Uint8Array", async () => { |
|||
const inner = { key: new Uint8Array([7, 8]) }; |
|||
const m = new Map<number, { key: Uint8Array }>([[42, inner]]); |
|||
const out = await roundTrip({ state: { m }, version: 0 }); |
|||
const m2 = out!.state.m as Map<number, { key: Uint8Array }>; |
|||
expect(m2 instanceof Map).toBe(true); |
|||
|
|||
const got = m2.get(42)!; |
|||
expect(got.key instanceof Uint8Array).toBe(true); |
|||
expect(Array.from(got.key)).toEqual([7, 8]); |
|||
}); |
|||
|
|||
it("round-trips deep nesting (array → object → map → u8)", async () => { |
|||
const payload = { |
|||
state: { |
|||
arr: [ |
|||
{ |
|||
obj: { |
|||
m: new Map<string, Uint8Array>([["k", new Uint8Array([9])]]), |
|||
}, |
|||
}, |
|||
], |
|||
}, |
|||
version: 0, |
|||
}; |
|||
const out = await roundTrip(payload); |
|||
const m2 = out!.state.arr[0].obj.m as Map<string, Uint8Array>; |
|||
expect(m2 instanceof Map).toBe(true); |
|||
const u = m2.get("k")!; |
|||
expect(u instanceof Uint8Array).toBe(true); |
|||
expect(Array.from(u)).toEqual([9]); |
|||
}); |
|||
|
|||
it("does not alter plain objects/arrays", async () => { |
|||
const payload = { state: { a: 1, b: [2, 3], c: { d: 4 } }, version: 0 }; |
|||
const out = await roundTrip(payload); |
|||
expect(out).toEqual(payload); |
|||
}); |
|||
|
|||
it("revives envelope-looking objects", async () => { |
|||
// Current implementation will treat any {__datatype:"Map",value:[...]} as an envelope.
|
|||
const forged = JSON.stringify({ |
|||
state: { m: { __datatype: "Map", value: [[1, "x"]] } }, |
|||
version: 0, |
|||
}); |
|||
const getSpy = vi.spyOn(idb, "get").mockResolvedValue(forged); |
|||
const out = await store.getItem("forged"); |
|||
expect(getSpy).toHaveBeenCalled(); |
|||
const m2 = out!.state.m as Map<number, string>; |
|||
expect(m2 instanceof Map).toBe(true); |
|||
expect(m2.get(1)).toBe("x"); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,19 @@ |
|||
import { createContext, useContext } from "react"; |
|||
|
|||
export type DeviceContext = { |
|||
deviceId: number; // Unique identifier for the device, not nodeNum
|
|||
}; |
|||
|
|||
export const CurrentDeviceContext = createContext<DeviceContext | undefined>( |
|||
undefined, |
|||
); |
|||
|
|||
export function useDeviceContext(): DeviceContext { |
|||
const ctx = useContext(CurrentDeviceContext); |
|||
if (!ctx) { |
|||
throw new Error( |
|||
"useDeviceContext must be used within CurrentDeviceContext provider", |
|||
); |
|||
} |
|||
return ctx; |
|||
} |
|||
Loading…
Reference in new issue