diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json new file mode 100644 index 000000000..82c018297 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json @@ -0,0 +1,1732 @@ +{ + "formatVersion": 1, + "database": { + "version": 49, + "identityHash": "a1b84a9235210248ec3b93dfa1faddbd", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "powerChannelLabels", + "columnName": "power_channel_labels", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "signsPackets", + "columnName": "has_xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translatedText", + "columnName": "translated_text", + "affinity": "TEXT" + }, + { + "fieldPath": "showTranslated", + "columnName": "show_translated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + }, + { + "fieldPath": "targets", + "columnName": "targets", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "deviceAddress", + "columnName": "device_address", + "affinity": "TEXT" + }, + { + "fieldPath": "homeLoraConfig", + "columnName": "home_lora_config", + "affinity": "BLOB" + }, + { + "fieldPath": "homePrimaryChannel", + "columnName": "home_primary_channel", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "infrastructureNodeCount", + "columnName": "infrastructure_node_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isInfrastructure", + "columnName": "is_infrastructure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_firmware_edition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))", + "fields": [ + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "welcomeMessage", + "columnName": "welcome_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventStart", + "columnName": "event_start", + "affinity": "TEXT" + }, + { + "fieldPath": "eventEnd", + "columnName": "event_end", + "affinity": "TEXT" + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "accentColor", + "columnName": "accent_color", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "themeJson", + "columnName": "theme_json", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareJson", + "columnName": "firmware_json", + "affinity": "TEXT" + }, + { + "fieldPath": "linksJson", + "columnName": "links_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "edition" + ] + } + }, + { + "tableName": "merge_marker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))", + "fields": [ + { + "fieldPath": "sourceDbName", + "columnName": "source_db_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mergedAt", + "columnName": "merged_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_db_name" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1b84a9235210248ec3b93dfa1faddbd')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index 46c040b6f..e98b40705 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -25,6 +25,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -45,6 +46,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.annotation.Named import org.koin.core.annotation.Single import org.meshtastic.core.common.util.normalizeAddress @@ -66,6 +68,16 @@ open class DatabaseManager( private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default) private val mutex = Mutex() + // Per-source write barrier for merges. `withDb` deliberately does NOT take [mutex] (hot path), so a merge under + // [mutex] must still drain any in-flight writer that captured the source DB before folding it away — otherwise a + // late-committing write is lost when the source is retired. This dedicated lock (never held across a drain await, + // so it can't deadlock the merge) tracks live `withDb` blocks per captured DB instance. It also guards the merge's + // active-DB swap so a writer either registers against `source` before the swap and is drained, or captures `dest` + // after it and is safe — it can never slip through the gap between the two. + private val writerTrackerMutex = Mutex() + private val activeWriters = mutableMapOf() + private val drainWaiters = mutableMapOf>>() + private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY) private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY) @@ -235,22 +247,23 @@ open class DatabaseManager( val source = _currentDb.value ?: return@withLock val dest = withContext(dispatchers.io) { getOrOpenDatabase(claimed) } - // Redirect live writers to the canonical DB BEFORE merging. Otherwise a concurrent withDb - // write (connect triggers a full NodeDB re-dump) could land in `source` after the merge has - // already snapshotted it, then be lost when `source` is retired — and withDb's closed-pool - // retry can't recover it because the write itself succeeded. New writers now capture `dest`; - // any still holding `source` are covered by that retry once retireDatabase closes it. - _currentDb.value = dest - currentDbName = claimed + // Redirect live writers to the canonical DB BEFORE merging (a connect triggers a full NodeDB + // re-dump). The swap is published under the writer lock so a concurrent withDb write registers + // against `source` before it — and is drained below — or captures `dest` after it and is safe. + // New writers now capture `dest`; drainWriters then waits out any still writing to `source` so + // the merge can't snapshot `source` mid-write and lose it when `source` is later retired. + publishActiveDb(dest, claimed) try { - withContext(dispatchers.io) { DatabaseMerger.merge(source, dest) } + withContext(dispatchers.io) { + drainWriters(source, sourceName) + DatabaseMerger.merge(source, dest, sourceName) + } } catch (e: CancellationException) { throw e } catch (e: Exception) { // merge() is atomic, so on failure `dest` is unchanged. Roll the active DB back to // `source` so the address still resolves consistently and the merge retries next connect. - _currentDb.value = source - currentDbName = sourceName + publishActiveDb(source, sourceName) Logger.w(e) { "Merge into ${anonymizeDbName(claimed)} failed; kept ${anonymizeDbName(sourceName)} active" } @@ -370,9 +383,57 @@ open class DatabaseManager( } } + /** Atomically snapshots the active DB and registers a writer against it. Returns null if none is open. */ + private suspend fun beginWrite(): MeshtasticDatabase? = writerTrackerMutex.withLock { + val db = _currentDb.value ?: return@withLock null + activeWriters[db] = (activeWriters[db] ?: 0) + 1 + db + } + + /** + * Registers a writer against a specific [db] — used by the withDb retry paths, whose target is a recovered/new + * instance rather than the snapshotted active DB, so their writes stay visible to a concurrent drain too. + */ + private suspend fun registerWriter(db: MeshtasticDatabase) = + writerTrackerMutex.withLock { activeWriters[db] = (activeWriters[db] ?: 0) + 1 } + + /** Deregisters a writer and releases any merge waiting for [db] to quiesce. Cancellation-safe (see call site). */ + private suspend fun endWrite(db: MeshtasticDatabase) = writerTrackerMutex.withLock { + val remaining = (activeWriters[db] ?: 1) - 1 + if (remaining <= 0) { + activeWriters.remove(db) + drainWaiters.remove(db)?.forEach { it.complete(Unit) } + } else { + activeWriters[db] = remaining + } + } + + /** Publishes [db]/[name] as active under the writer lock so concurrent [beginWrite]s order against the swap. */ + private suspend fun publishActiveDb(db: MeshtasticDatabase, name: String) = writerTrackerMutex.withLock { + _currentDb.value = db + currentDbName = name + } + + /** + * Suspends until every writer that captured [db] before this call has finished, so a merge never snapshots [db] + * while a write is still in flight (and then loses it when [db] is retired). Bounded by [WRITER_DRAIN_TIMEOUT_MS] + * so a wedged writer can't pin the merge — and [mutex] — forever; falling through on timeout is no worse than the + * old barrier-less behavior for that rare case. + */ + private suspend fun drainWriters(db: MeshtasticDatabase, dbName: String) { + val waiter = + writerTrackerMutex.withLock { + if ((activeWriters[db] ?: 0) == 0) return + CompletableDeferred().also { drainWaiters.getOrPut(db) { mutableListOf() }.add(it) } + } + if (withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { waiter.await() } == null) { + Logger.w { "Timed out draining writers on ${anonymizeDbName(dbName)} before merge" } + } + } + @Suppress("ReturnCount", "ThrowsCount", "TooGenericExceptionCaught", "CyclomaticComplexMethod") private suspend fun withCurrentDb(block: suspend (MeshtasticDatabase) -> T): T? { - val db = _currentDb.value ?: return null + val db = beginWrite() ?: return null val active = currentDbName markLastUsed(active) try { @@ -385,14 +446,7 @@ open class DatabaseManager( val retryDb = _currentDb.value if (retryDb != null && retryDb !== db && isDbClosedException(e)) { Logger.w { "withDb: database closed during switch (${e.message}), retrying with current DB" } - return try { - runCancellableDbBlock(retryDb, block) - } catch (retryCancel: CancellationException) { - throw retryCancel - } catch (retryEx: Exception) { - retryEx.addSuppressed(e) - throw retryEx - } + return retryRegisteredDbBlock(retryDb, e, block) } // Same active DB but Room's connection pool is wedged — reopen onto a fresh active instance once. @@ -406,17 +460,40 @@ open class DatabaseManager( "withDb: active DB switched during timeout recovery; retrying with current DB" } } - return try { - runCancellableDbBlock(recoveredDb, block) - } catch (retryCancel: CancellationException) { - throw retryCancel - } catch (retryEx: Exception) { - retryEx.addSuppressed(e) - throw retryEx - } + return retryRegisteredDbBlock(recoveredDb, e, block) } throw e + } finally { + // NonCancellable so a cancelled withDb still deregisters — a leaked +1 would make every future + // drain on this DB instance time out. + withContext(NonCancellable) { endWrite(db) } + } + } + + /** + * Retries [block] against [db] — the recovered/new instance a withDb retry targets instead of the DB it originally + * registered against. Registers a writer on [db] for the duration so the retry write stays visible to a concurrent + * merge draining [db], with the same NonCancellable deregistration guarantee as [withCurrentDb]'s outer + * registration (which remains held on the original DB until that finally runs — the overlap is harmless, counts + * balance per instance). Any retry failure carries the original failure [cause] as a suppressed exception. + */ + @Suppress("TooGenericExceptionCaught") + private suspend fun retryRegisteredDbBlock( + db: MeshtasticDatabase, + cause: Exception, + block: suspend (MeshtasticDatabase) -> T, + ): T { + registerWriter(db) + try { + return runCancellableDbBlock(db, block) + } catch (retryCancel: CancellationException) { + throw retryCancel + } catch (retryEx: Exception) { + retryEx.addSuppressed(cause) + throw retryEx + } finally { + withContext(NonCancellable) { endWrite(db) } } } @@ -439,6 +516,11 @@ open class DatabaseManager( internal companion object { private const val BACKFILL_COLD_START_DELAY_MS = 2_000L private const val WITH_DB_SLOW_OPERATION_MS = 1_000L + + /** + * Upper bound on how long a merge waits for in-flight writers on the source DB to drain (see [drainWriters]). + */ + private const val WRITER_DRAIN_TIMEOUT_MS = 5_000L val DB_TERMS = listOf("pool", "database", "connection", "sqlite") private const val ROOM_POOL_ACQUIRE_TIMEOUT_PHRASE = "timed out attempting to acquire" diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt index 41b107dd8..313f9eaaf 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt @@ -19,6 +19,8 @@ package org.meshtastic.core.database import androidx.room3.immediateTransaction import androidx.room3.useWriterConnection import co.touchlab.kermit.Logger +import org.meshtastic.core.common.util.nowMillis +import org.meshtastic.core.database.entity.MergeMarkerEntity /** * Folds the contents of one device database ([source]) into another ([dest]) when both turn out to belong to the same @@ -36,15 +38,26 @@ import co.touchlab.kermit.Logger */ object DatabaseMerger { - suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase) { + /** + * Folds [source] into [dest], skipping the work if [sourceName] was already merged into [dest]. [sourceName] is the + * source database's file name — the stable key under which the merge is recorded (see [MergeMarkerEntity]). + */ + suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase, sourceName: String) { // All destination writes run in a single transaction so a crash or exception mid-merge rolls - // back cleanly instead of leaving `dest` half-merged. This also makes a retried merge safe: if - // associateDevice re-runs (e.g. a crash before the address alias is persisted), the rolled-back - // partial writes never happened, so the fresh-id packet/discovery re-inserts can't duplicate. - // Reads from `source` use its own connection pool and don't participate in this transaction. + // back cleanly instead of leaving `dest` half-merged. The merge marker is written inside that + // same transaction, so it commits atomically with the copied rows: on a retried merge (e.g. a + // crash after commit but before associateDevice persisted the address alias) the marker is already + // present and the whole merge is skipped, so the fresh-id packet/discovery re-inserts — which are + // NOT idempotent on their own — can never duplicate. Reads from `source` use its own connection + // pool and don't participate in this transaction. var packets = 0 + var skipped = false dest.useWriterConnection { transactor -> transactor.immediateTransaction { + if (dest.mergeMarkerDao().isMerged(sourceName)) { + skipped = true + return@immediateTransaction + } packets = mergePackets(source, dest) mergeReactions(source, dest) mergeContactSettings(source, dest) @@ -55,9 +68,14 @@ object DatabaseMerger { mergeLogs(source, dest) mergeTraceroutePositions(source, dest) mergeDiscovery(source, dest) + dest.mergeMarkerDao().insertMarker(MergeMarkerEntity(sourceDbName = sourceName, mergedAt = nowMillis)) } } - Logger.i { "Merged $packets packets across transports into unified node DB" } + if (skipped) { + Logger.i { "Source ${anonymizeDbName(sourceName)} already merged; skipped duplicate merge" } + } else { + Logger.i { "Merged $packets packets across transports into unified node DB" } + } } /** diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 73076e4e7..a7e237369 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -30,6 +30,7 @@ import org.meshtastic.core.database.dao.DeviceLinkDao import org.meshtastic.core.database.dao.DiscoveryDao import org.meshtastic.core.database.dao.EventFirmwareEditionDao import org.meshtastic.core.database.dao.FirmwareReleaseDao +import org.meshtastic.core.database.dao.MergeMarkerDao import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao import org.meshtastic.core.database.dao.PacketDao @@ -43,6 +44,7 @@ import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity import org.meshtastic.core.database.entity.DiscoverySessionEntity import org.meshtastic.core.database.entity.EventFirmwareEditionEntity import org.meshtastic.core.database.entity.FirmwareReleaseEntity +import org.meshtastic.core.database.entity.MergeMarkerEntity import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity @@ -73,6 +75,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity DiscoveryPresetResultEntity::class, DiscoveredNodeEntity::class, EventFirmwareEditionEntity::class, + MergeMarkerEntity::class, ], autoMigrations = [ @@ -121,8 +124,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 45, to = 46), AutoMigration(from = 46, to = 47), AutoMigration(from = 47, to = 48), + AutoMigration(from = 48, to = 49), ], - version = 48, + version = 49, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) @@ -149,6 +153,8 @@ abstract class MeshtasticDatabase : RoomDatabase() { abstract fun eventFirmwareEditionDao(): EventFirmwareEditionDao + abstract fun mergeMarkerDao(): MergeMarkerDao + companion object { /** * Configures a [RoomDatabase.Builder] with standard settings for this project. diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt new file mode 100644 index 000000000..5813384a2 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.Dao +import androidx.room3.Insert +import androidx.room3.OnConflictStrategy +import androidx.room3.Query +import org.meshtastic.core.database.entity.MergeMarkerEntity + +/** Tracks which source databases have already been merged into this (canonical) database. See [MergeMarkerEntity]. */ +@Dao +interface MergeMarkerDao { + + /** IGNORE keeps the earliest marker on a re-run; the row's mere existence is what matters, not its timestamp. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertMarker(marker: MergeMarkerEntity) + + @Query("SELECT EXISTS(SELECT 1 FROM merge_marker WHERE source_db_name = :sourceDbName)") + suspend fun isMerged(sourceDbName: String): Boolean +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt new file mode 100644 index 000000000..4493ea020 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.entity + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.PrimaryKey + +/** + * A durable record, in the canonical (destination) database, that a given source database has already been folded in by + * [org.meshtastic.core.database.DatabaseMerger]. Written inside the same merge transaction as the copied rows, so it + * commits atomically with them. When [org.meshtastic.core.database.DatabaseManager.associateDevice] re-runs the merge + * on the next connection, `DatabaseMerger.merge` finds the marker at the top of its transaction and skips the whole + * merge — closing the crash window between the merge commit and the datastore alias write, where a retry would + * otherwise duplicate the fresh-id packet/discovery rows. Markers share the destination DB's lifecycle: if the + * canonical DB is LRU-evicted and later recreated empty, its markers vanish with the merged data they describe. + */ +@Entity(tableName = "merge_marker") +data class MergeMarkerEntity( + @PrimaryKey @ColumnInfo(name = "source_db_name") val sourceDbName: String, + @ColumnInfo(name = "merged_at") val mergedAt: Long = 0L, +) diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt index e91ac184f..a8fb3b482 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt @@ -52,6 +52,9 @@ class DatabaseMergerTest { // Same physical node reached over two transports → same myNodeNum in both DBs. private val nodeNum = 0x1234 + // The source DB's file name — the key under which a completed merge is recorded in `dest`. + private val sourceName = "meshtastic_database_source" + private val source: MeshtasticDatabase = getInMemoryDatabaseBuilder().build() private val dest: MeshtasticDatabase = getInMemoryDatabaseBuilder().build() @@ -171,7 +174,7 @@ class DatabaseMergerTest { .discoveryDao() .insertDiscoveredNodes(listOf(DiscoveredNodeEntity(presetResultId = srcPreset, nodeNum = 30L))) - DatabaseMerger.merge(source, dest) + DatabaseMerger.merge(source, dest, sourceName) // Packets: 1 + 2, with fresh distinct non-zero uuids (no collision with dest's existing uuid). val packets = dest.packetDao().getAllPacketsSnapshot() @@ -220,4 +223,51 @@ class DatabaseMergerTest { "node appended under rewired preset id", ) } + + /** + * Regression for #6230: a crash after the merge transaction commits but before the address alias is persisted + * re-runs [DatabaseMerger.merge] on the next connection. The non-idempotent tables (packets, discovery) must not be + * duplicated. The durable per-source merge marker, written inside the merge transaction, makes the second merge a + * no-op. + */ + @Test + fun mergeIsIdempotentAcrossRetry() = runTest { + source.nodeInfoDao().setMyNodeInfo(myNode()) + source.packetDao().insert(textPacket("0!broadcast", "srcmsgone", time = 200)) + source.packetDao().insert(textPacket("0!broadcast", "srcmsgtwo", time = 300)) + val srcSession = + source + .discoveryDao() + .insertSession( + DiscoverySessionEntity(timestamp = 1, presetsScanned = "LongFast", homePreset = "LongFast"), + ) + val srcPreset = + source + .discoveryDao() + .insertPresetResult(DiscoveryPresetResultEntity(sessionId = srcSession, presetName = "LongFast")) + source + .discoveryDao() + .insertDiscoveredNodes(listOf(DiscoveredNodeEntity(presetResultId = srcPreset, nodeNum = 30L))) + + DatabaseMerger.merge(source, dest, sourceName) + + val packetsAfterFirst = dest.packetDao().getAllPacketsSnapshot().size + val sessionsAfterFirst = dest.discoveryDao().getAllSessionsSnapshot().size + assertEquals(2, packetsAfterFirst, "both source packets merged once") + assertEquals(1, sessionsAfterFirst, "source discovery session merged once") + + // Simulate the crash-window retry: same source folded into the same dest again. + DatabaseMerger.merge(source, dest, sourceName) + + assertEquals( + packetsAfterFirst, + dest.packetDao().getAllPacketsSnapshot().size, + "packets not duplicated on retry", + ) + assertEquals( + sessionsAfterFirst, + dest.discoveryDao().getAllSessionsSnapshot().size, + "discovery sessions not duplicated on retry", + ) + } }