diff options
87 files changed, 1757 insertions, 675 deletions
diff --git a/.sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json b/.sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json index 4770b7e..fd5a165 100644 --- a/.sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json +++ b/.sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\t\t\tinsert into message\n\t\t\t\t\t(id, channel, sender, sent_at, sent_sequence, body)\n\t\t\t\tvalues ($1, $2, $3, $4, $5, $6)\n\t\t\t\treturning\n\t\t\t\t\tid as \"id: Id\",\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n\t\t\t\t\tbody\n\t\t\t", + "query": "\n insert into message\n (id, channel, sender, sent_at, sent_sequence, body)\n values ($1, $2, $3, $4, $5, $6)\n returning\n id as \"id: Id\",\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n body as \"body: Body\"\n ", "describe": { "columns": [ { @@ -29,7 +29,7 @@ "type_info": "Integer" }, { - "name": "body", + "name": "body: Body", "ordinal": 5, "type_info": "Text" } @@ -46,5 +46,5 @@ true ] }, - "hash": "836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418" + "hash": "0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d" } diff --git a/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json b/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json new file mode 100644 index 0000000..f8dd228 --- /dev/null +++ b/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n channel.id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n left join message\n on channel.id = message.channel\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Null" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9" +} diff --git a/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json b/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json deleted file mode 100644 index 2c7666f..0000000 --- a/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0" -} diff --git a/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json b/.sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json index 0caa950..9f09a28 100644 --- a/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json +++ b/.sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence > $1, true)\n ", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body as \"body: Body\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence > $1, true)\n ", "describe": { "columns": [ { @@ -29,7 +29,7 @@ "type_info": "Integer" }, { - "name": "body", + "name": "body: Body", "ordinal": 5, "type_info": "Text" }, @@ -58,5 +58,5 @@ true ] }, - "hash": "400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91" + "hash": "24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269" } diff --git a/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json b/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json new file mode 100644 index 0000000..2de9614 --- /dev/null +++ b/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a" +} diff --git a/.sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json b/.sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json index 63ff012..227d242 100644 --- a/.sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json +++ b/.sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.sent_at < $1\n and deleted.id is null\n ", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body as \"body: Body\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.sent_at < $1\n and deleted.id is null\n ", "describe": { "columns": [ { @@ -29,7 +29,7 @@ "type_info": "Integer" }, { - "name": "body", + "name": "body: Body", "ordinal": 5, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca" + "hash": "2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98" } diff --git a/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json b/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json new file mode 100644 index 0000000..c1da170 --- /dev/null +++ b/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c" +} diff --git a/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json b/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json new file mode 100644 index 0000000..1105391 --- /dev/null +++ b/.sqlx/query-31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update channel_name\n set canonical_name = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "31e741181f0d09540063ef29117e7e70d3df2beaa1b1e2e081b0d362c07ceae8" +} diff --git a/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json b/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json deleted file mode 100644 index 01e72b2..0000000 --- a/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into login (id, name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5)\n returning\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 5 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60" -} diff --git a/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json b/.sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json index ba35bb9..3147d7f 100644 --- a/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json +++ b/.sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.channel = $1\n and deleted.id is null\n ", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.channel = $1\n and deleted.id is null\n ", "describe": { "columns": [ { @@ -19,7 +19,7 @@ "type_info": "Text" }, { - "name": "body", + "name": "body: Body", "ordinal": 3, "type_info": "Text" }, @@ -58,5 +58,5 @@ true ] }, - "hash": "e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1" + "hash": "40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11" } diff --git a/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json b/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json deleted file mode 100644 index 767c217..0000000 --- a/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a" -} diff --git a/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json b/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json new file mode 100644 index 0000000..ebddeb8 --- /dev/null +++ b/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from channel_name\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f" +} diff --git a/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json b/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json new file mode 100644 index 0000000..8962098 --- /dev/null +++ b/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_name (id, display_name, canonical_name)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0" +} diff --git a/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json new file mode 100644 index 0000000..5442790 --- /dev/null +++ b/.sqlx/query-642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from channel_name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "642fb12657410a4bee58d316293ea1bcc4913987ee751951e9d2f31bf495f305" +} diff --git a/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json b/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json deleted file mode 100644 index f706f81..0000000 --- a/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel.id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n left join message\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2" -} diff --git a/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json b/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json new file mode 100644 index 0000000..fd601e9 --- /dev/null +++ b/.sqlx/query-676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\"\n from login\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "676a7dda6314cae4d13ff51241dc35263ccfee9f3424111e7fa6014071f98a1e" +} diff --git a/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json b/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json new file mode 100644 index 0000000..93a4093 --- /dev/null +++ b/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: login::Id\"\n ", + "describe": { + "columns": [ + { + "name": "token: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: login::Id", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91" +} diff --git a/.sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json b/.sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json index 5a038ac..09440ca 100644 --- a/.sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json +++ b/.sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where id = $1\n ", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where id = $1\n ", "describe": { "columns": [ { @@ -19,7 +19,7 @@ "type_info": "Text" }, { - "name": "body", + "name": "body: Body", "ordinal": 3, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be" + "hash": "78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5" } diff --git a/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json b/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json deleted file mode 100644 index edd3825..0000000 --- a/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c" -} diff --git a/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json b/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json deleted file mode 100644 index 4d28006..0000000 --- a/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by channel.name\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719" -} diff --git a/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json b/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json new file mode 100644 index 0000000..cf1afec --- /dev/null +++ b/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from login\n where canonical_name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "password_hash: StoredHash", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6" +} diff --git a/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json b/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json new file mode 100644 index 0000000..d4bb122 --- /dev/null +++ b/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by name.canonical_name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79" +} diff --git a/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json b/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json deleted file mode 100644 index 56a8498..0000000 --- a/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into channel (id, name, created_at, created_sequence)\n values ($1, $2, $3, $4)\n returning\n id as \"id: Id\",\n name as \"name!\", -- known non-null as we just set it\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name!", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false, - true, - false, - false - ] - }, - "hash": "9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46" -} diff --git a/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json b/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json new file mode 100644 index 0000000..647e5ad --- /dev/null +++ b/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd" +} diff --git a/.sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json b/.sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json index 7c83aa1..7d9bbbc 100644 --- a/.sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json +++ b/.sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", "describe": { "columns": [ { @@ -9,18 +9,23 @@ "type_info": "Text" }, { - "name": "name", + "name": "display_name: String", "ordinal": 1, "type_info": "Text" }, { - "name": "created_sequence: Sequence", + "name": "canonical_name: String", "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, "type_info": "Integer" }, { "name": "created_at: DateTime", - "ordinal": 3, + "ordinal": 4, "type_info": "Text" } ], @@ -31,8 +36,9 @@ false, false, false, + false, false ] }, - "hash": "b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3" + "hash": "ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8" } diff --git a/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json b/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json deleted file mode 100644 index cb345dc..0000000 --- a/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n token.id as \"token_id: Id\",\n login.id as \"login_id: login::Id\",\n login.name as \"login_name\"\n from login\n join token on login.id = token.login\n where token.secret = $1\n ", - "describe": { - "columns": [ - { - "name": "token_id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login_id: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "login_name", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404" -} diff --git a/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json b/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json deleted file mode 100644 index 5825ce8..0000000 --- a/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert into channel_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n returning 1 as \"deleted: bool\"\n ", - "describe": { - "columns": [ - { - "name": "deleted: bool", - "ordinal": 0, - "type_info": "Null" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - null - ] - }, - "hash": "b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0" -} diff --git a/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json b/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json new file mode 100644 index 0000000..677495b --- /dev/null +++ b/.sqlx/query-b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n update login\n set canonical_name = $1\n where id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b67d56f20dab413e31a64842bfc6c82b1464afa98845e537e850d05deb328f06" +} diff --git a/.sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json b/.sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json index 3901207..aa20875 100644 --- a/.sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json +++ b/.sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: login::Id\",\n\t\t\t\t\tname,\n\t\t\t\t\tpassword_hash as \"password_hash: StoredHash\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n\t\t\t\tfrom login\n\t\t\t\twhere name = $1\n\t\t\t", + "query": "\n select\n id as \"id: login::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where id = $1\n ", "describe": { "columns": [ { @@ -9,12 +9,12 @@ "type_info": "Text" }, { - "name": "name", + "name": "display_name: String", "ordinal": 1, "type_info": "Text" }, { - "name": "password_hash: StoredHash", + "name": "canonical_name: String", "ordinal": 2, "type_info": "Text" }, @@ -40,5 +40,5 @@ false ] }, - "hash": "b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa" + "hash": "c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f" } diff --git a/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json b/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json deleted file mode 100644 index 01bb071..0000000 --- a/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update channel\n set name = null\n where id = $1\n returning 1 as \"updated: bool\"\n ", - "describe": { - "columns": [ - { - "name": "updated: bool", - "ordinal": 0, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - null - ] - }, - "hash": "c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87" -} diff --git a/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json b/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json new file mode 100644 index 0000000..658728c --- /dev/null +++ b/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into channel (id, created_at, created_sequence)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42" +} diff --git a/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json b/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json new file mode 100644 index 0000000..4efac0c --- /dev/null +++ b/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into login (id, display_name, canonical_name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0" +} diff --git a/.sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json b/.sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json index 39e9231..662e8c2 100644 --- a/.sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json +++ b/.sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\t\t\tselect\n invite.id as \"invite_id: Id\",\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.name as \"issuer_name\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t", + "query": "\n\t\t\t\tselect\n invite.id as \"invite_id: Id\",\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.display_name as \"issuer_name: nfc::String\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t", "describe": { "columns": [ { @@ -14,7 +14,7 @@ "type_info": "Text" }, { - "name": "issuer_name", + "name": "issuer_name: nfc::String", "ordinal": 2, "type_info": "Text" }, @@ -34,5 +34,5 @@ false ] }, - "hash": "d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a" + "hash": "e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b" } diff --git a/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json b/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json deleted file mode 100644 index 1883035..0000000 --- a/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc" -} diff --git a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json new file mode 100644 index 0000000..6700c43 --- /dev/null +++ b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name?: String\",\n name.canonical_name as \"canonical_name?: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name?: String", + "ordinal": 1, + "type_info": "Null" + }, + { + "name": "canonical_name?: String", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e" +} diff --git a/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json b/.sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json index abc1851..7aab764 100644 --- a/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json +++ b/.sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence <= $2, true)\n order by message.sent_sequence\n ", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence <= $2, true)\n order by message.sent_sequence\n ", "describe": { "columns": [ { @@ -19,7 +19,7 @@ "type_info": "Text" }, { - "name": "body", + "name": "body: Body", "ordinal": 3, "type_info": "Text" }, @@ -58,5 +58,5 @@ true ] }, - "hash": "bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d" + "hash": "fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24" } @@ -825,6 +825,8 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "unicode-casefold", + "unicode-normalization", "uuid", ] @@ -2108,6 +2110,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" [[package]] +name = "unicode-casefold" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f" + +[[package]] name = "unicode-ident" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3,6 +3,7 @@ name = "hi" version = "0.1.0" edition = "2021" rust-version = "1.82" +default-run = "hi" [dependencies] argon2 = "0.5.3" @@ -31,6 +32,8 @@ sqlx = { version = "=0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] } thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } +unicode-casefold = "0.2.0" +unicode-normalization = "0.1.24" uuid = { version = "1.11.0", features = ["v4"] } [dev-dependencies] diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 7e05443..135e91b 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -13,6 +13,23 @@ stateDiagram-v2 Authentication associates each authenticated request with a login. +To create logins, see [initial setup](./initial-setup.md) and [invitations](./invitations.md). + + +## Names + +<!-- This prose is duplicated in channels-messages.md. If you change it here, consider changing it there, too. --> +The service handles login names using two separate forms. + +The first form is as given in the request used to create the login. This form of the login name is used throughout the API, and the service will preserve the name as entered (other than applying normalization), so that users' preferences around capitalization and accent marks are preserved. + +The second form is a "canonical" form, used internally by the service to control uniqueness and match names to logins. The canonical form is both case-folded and normalized. + +The canonical form is not available to API clients, but its use has practical consequences: + +* Names that differ only by case or only by code point sequence are treated as the same name. If the name is in use, changing the capitalization or changing the sequence of combining marks will not allow the creation of a second "identical" login. +* The login API accepts any name that canonicalizes to the form stored in the database, making login names effectively case-insensitive. + ## Identity tokens @@ -32,8 +49,6 @@ Unless the endpoint's documentation says otherwise, all endpoints require authen Authenticates the user using their login name and password. The login must exist before calling this endpoint. -To create logins, see [initial setup](./initial-setup.md) and [invitations](./invitations.md). - **This endpoint does not require an `identity` cookie.** ### Request diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index 1ff037d..9ef4e66 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -27,6 +27,18 @@ Messages allow logins to communicate with one another. Channels are the conversa Every channel has a unique name, chosen when the channel is created. +## Names + +<!-- This prose is duplicated in authentication.md. If you change it here, consider changing it there, too. --> +The service handles channel names using two separate forms. + +The first form is as given in the request used to create the channel. This form of the channel name is used throughout the API, and the service will preserve the name as entered (other than applying normalization), so that users' preferences around capitalization and accent marks are preserved. + +The second form is a "canonical" form, used internally by the service to control uniqueness and match names to channels. The canonical form is both case-folded and normalized. + +The canonical form is not available to API clients, but its use has practical consequences. Names that differ only by case or only by code point sequence are treated as the same name. If the name is in use, changing the capitalization or changing the sequence of combining marks will not allow the creation of a second "identical" channel. + + ## Expiry and purging Both channels and messages expire after a time. Messages expire 90 days after being sent. Channels expire 90 days after the last message sent to them, or after creation if no messages are sent in that time. @@ -70,6 +82,8 @@ The response will have the following fields: | `id` | string | A unique identifier for the channel. This can be used to associate the channel with events, or to make API calls targeting the channel. | | `name` | string | The channel's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + When completed, the service will emit a [channel created](events.md#channel-created) event with the channel's ID. ### Duplicate channel name @@ -125,6 +139,8 @@ The response will have the following fields: | `id` | string | A unique identifier for the message. This can be used to associate the message with events, or to make API calls targeting the message. | | `body` | string | The message's body. | +The returned message body may not be identical to the body as sent, as the body will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned body will include this normalization; the service will use the normalized body elsewhere, and does not store the originally submitted body. + When completed, the service will emit a [message sent](events.md#message-sent) event with the message's ID. ### Invalid channel ID diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index 3c5a8a6..306d798 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -71,6 +71,10 @@ The response will include the following fields: | `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | | `name` | string | The login's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + +The provided password will also be converted to normalization form C. However, the normalized password is not returned to the client. + The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the initial login created for this request. See the [authentication](./authentication) section for details on how this cookie may be used. The cookie will expire if it is not used regularly. diff --git a/docs/api/invitations.md b/docs/api/invitations.md index d3431d7..ddbef8a 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -150,6 +150,10 @@ The response will include the following fields: | `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | | `name` | string | The login's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + +The provided password will also be converted to normalization form C. However, the normalized password is not returned to the client. + The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the login created for this request. See the [authentication](./authentication.md) section for details on how this cookie may be used. The cookie will expire if it is not used regularly. diff --git a/docs/internal-server-errors.md b/docs/internal-server-errors.md new file mode 100644 index 0000000..4f679b7 --- /dev/null +++ b/docs/internal-server-errors.md @@ -0,0 +1,30 @@ +# Internal Server Errors + +When `hi` encounters a problem that prevents a request from completing, it may report a `500 Internal Server Error` to clients, along with an error code. The actual error will be printed to standard error, with the error code. The following sections describe errors we've encountered, the likely operational consequences, and recommend approaches for addressing them. + +## database is locked + +The server attempted two write transactions at the same time, and encountered [sqlite's write locks](https://www.sqlite.org/rescode.html#busy). This is unfortunately unavoidable, but generally only occurs as a result of extremely bad luck, or very high load. + +This error will almost always resolve itself if clients re-try their requests; no further action is needed. + +This is a known issue. If you are encountering this consistently (or if you can trigger it on demand), let us know. We are aware of sqlite's features for mitigating this issue but have been unsuccessful in applying them; we're working on it, but patches _are_ welcome, if you have the opportunity. + +## stored canonical form […] does not match computed canonical form […] for name […] + +When `hi` applies the `migrations/20241019191531_canonical_names.sql` migration (from commit `3f9648eed48cd8b6cd35d0ae2ee5bbe25fa735ac`), this can leave existing names in a state where the stored canonical form is not the correct canonicalization of the stored display names of channels and logins. `hi` will abort requests when it encounters this situation, to avoid incorrect behaviours such as duplicate channels or duplicate logins. + +As channel and login names may be presented during client startup, this can render the service unusable until repaired. Treat this as an immediate outage if you see it. + +You can verify that login names are unique by running the following commands as the user the `hi` server runs as: + +* `sqlite3 .hi 'select display_name from login'` +* `sqlite3 .hi 'select display_name from channel_name'` + +Substitute `.hi` with the path to your `hi` database if it differs from the default. + +If the names are unique, you can repair the database: + +* Stop the `hi` server. +* Run `hi-recanonicalize`, as the same user the `hi` server runs as, with the same database options. +* Start the `hi` server. diff --git a/migrations/20241019191531_canonical_names.sql b/migrations/20241019191531_canonical_names.sql new file mode 100644 index 0000000..ab7cbf4 --- /dev/null +++ b/migrations/20241019191531_canonical_names.sql @@ -0,0 +1,189 @@ +alter table login +rename to old_login; +alter table token +rename to old_token; +alter table invite +rename to old_invite; +alter table channel +rename to old_channel; +alter table channel_deleted +rename to old_channel_deleted; +alter table message +rename to old_message; +alter table message_deleted +rename to old_message_deleted; + +create table login ( + id text + not null + primary key, + display_name text + not null, + canonical_name text + not null + unique, + password_hash text + not null, + created_sequence bigint + unique + not null, + created_at text + not null +); + +insert into login (id, display_name, canonical_name, password_hash, created_sequence, created_at) +select + id, + -- This isn't strictly correct, as existing names are not guaranteed to be + -- normalized, and as sqlite's built-in `lower` only operates on ASCII, but + -- without any way to do case folding and normalization in sqlite3, and + -- without any way to call out into Rust to do the work, this is the best we + -- can do. Normalization issues will produce errors at runtime when the + -- relevant rows are loaded. + name as display_name, + lower(name) as canonical_name, + password_hash, + created_sequence, + created_at +from old_login; + +create table token ( + id text + not null + primary key, + secret text + not null + unique, + login text + not null + references login (id), + issued_at text + not null, + last_used_at text + not null +); + +insert into token (id, secret, login, issued_at, last_used_at) +select id, secret, login, issued_at, last_used_at from old_token; + +create table invite ( + id text + primary key + not null, + issuer text + not null + references login (id), + issued_at text + not null +); + +insert into invite (id, issuer, issued_at) +select id, issuer, issued_at +from old_invite; + +create table channel ( + id text + not null + primary key, + created_sequence bigint + unique + not null, + created_at text + not null +); + +create table channel_name ( + id text + not null + primary key + references channel (id), + display_name + not null, + canonical_name + not null + unique +); + +insert into channel (id, created_sequence, created_at) +select id, created_sequence, created_at from old_channel; + +insert into channel_name (id, display_name, canonical_name) +select + id, + -- This isn't strictly correct, for the same reasons as above. + name as display_name, + name as canonical_name +from old_channel +where name is not null; + +create table channel_deleted ( + id text + not null + primary key + references channel (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +insert into channel_deleted (id, deleted_sequence, deleted_at) +select id, deleted_sequence, deleted_at +from old_channel_deleted; + +create table message ( + id text + not null + primary key, + -- Starting from the code changes in this rev, values in this column will be + -- in NFC. However, we don't actually need to normalize historical values; + -- they're delivered to clients "verbatim" with respect to how they were sent, + -- which causes no harm. + channel text + not null + references channel (id), + sender text + not null + references login (id), + sent_sequence bigint + unique + not null, + sent_at text + not null, + body text + null +); + +insert into message (id, channel, sender, sent_sequence, sent_at, body) +select id, channel, sender, sent_sequence, sent_at, body +from old_message; + +create table message_deleted ( + id text + not null + primary key + references message (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +insert into message_deleted (id, deleted_sequence, deleted_at) +select id, deleted_sequence, deleted_at +from old_message_deleted; + +drop table old_message_deleted; +drop table old_message; +drop table old_channel_deleted; +drop table old_channel; +drop table old_invite; +drop table old_token; +drop table old_login; + +create index token_issued_at +on token (issued_at); +create index message_sent_at +on message (sent_at); @@ -5,14 +5,12 @@ use crate::{ channel::app::Channels, event::{self, app::Events}, invite::app::Invites, + login::app::Logins, message::app::Messages, setup::app::Setup, token::{self, app::Tokens}, }; -#[cfg(test)] -use crate::login::app::Logins; - #[derive(Clone)] pub struct App { db: SqlitePool, @@ -49,6 +47,11 @@ impl App { Invites::new(&self.db) } + #[cfg(not(test))] + pub const fn logins(&self) -> Logins { + Logins::new(&self.db) + } + #[cfg(test)] pub const fn logins(&self) -> Logins { Logins::new(&self.db, &self.events) diff --git a/src/bin/hi-recanonicalize.rs b/src/bin/hi-recanonicalize.rs new file mode 100644 index 0000000..4081276 --- /dev/null +++ b/src/bin/hi-recanonicalize.rs @@ -0,0 +1,9 @@ +use clap::Parser; + +use hi::cli; + +#[tokio::main] +async fn main() -> Result<(), cli::recanonicalize::Error> { + let args = cli::recanonicalize::Args::parse(); + args.run().await +} diff --git a/src/main.rs b/src/bin/hi.rs index d0830ff..d0830ff 100644 --- a/src/main.rs +++ b/src/bin/hi.rs diff --git a/src/boot/app.rs b/src/boot/app.rs index ef48b2f..1d88608 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -2,8 +2,11 @@ use sqlx::sqlite::SqlitePool; use super::Snapshot; use crate::{ - channel::repo::Provider as _, event::repo::Provider as _, login::repo::Provider as _, + channel::{self, repo::Provider as _}, + event::repo::Provider as _, + login::{self, repo::Provider as _}, message::repo::Provider as _, + name, }; pub struct Boot<'a> { @@ -15,7 +18,7 @@ impl<'a> Boot<'a> { Self { db } } - pub async fn snapshot(&self) -> Result<Snapshot, sqlx::Error> { + pub async fn snapshot(&self) -> Result<Snapshot, Error> { let mut tx = self.db.begin().await?; let resume_point = tx.sequence().current().await?; @@ -48,3 +51,30 @@ impl<'a> Boot<'a> { }) } } + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Name(#[from] name::Error), + Database(#[from] sqlx::Error), +} + +impl From<login::repo::LoadError> for Error { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Name(error) => error.into(), + LoadError::Database(error) => error.into(), + } + } +} + +impl From<channel::repo::LoadError> for Error { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Name(error) => error.into(), + LoadError::Database(error) => error.into(), + } + } +} diff --git a/src/channel/app.rs b/src/channel/app.rs index 75c662d..7bfa0f7 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -2,12 +2,16 @@ use chrono::TimeDelta; use itertools::Itertools; use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Channel, History, Id}; +use super::{ + repo::{LoadError, Provider as _}, + Channel, History, Id, +}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::{repo::Provider as _, Broadcaster, Event, Sequence}, message::repo::Provider as _, + name::{self, Name}, }; pub struct Channels<'a> { @@ -20,14 +24,14 @@ impl<'a> Channels<'a> { Self { db, events } } - pub async fn create(&self, name: &str, created_at: &DateTime) -> Result<Channel, CreateError> { + pub async fn create(&self, name: &Name, created_at: &DateTime) -> Result<Channel, CreateError> { let mut tx = self.db.begin().await?; let created = tx.sequence().next(created_at).await?; let channel = tx .channels() .create(name, &created) .await - .duplicate(|| CreateError::DuplicateName(name.into()))?; + .duplicate(|| CreateError::DuplicateName(name.clone()))?; tx.commit().await?; self.events @@ -38,7 +42,7 @@ impl<'a> Channels<'a> { // This function is careless with respect to time, and gets you the channel as // it exists in the specific moment when you call it. - pub async fn get(&self, channel: &Id) -> Result<Option<Channel>, sqlx::Error> { + pub async fn get(&self, channel: &Id) -> Result<Option<Channel>, Error> { let mut tx = self.db.begin().await?; let channel = tx.channels().by_id(channel).await.optional()?; tx.commit().await?; @@ -88,7 +92,7 @@ impl<'a> Channels<'a> { Ok(()) } - pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + pub async fn expire(&self, relative_to: &DateTime) -> Result<(), ExpireError> { // Somewhat arbitrarily, expire after 90 days. let expire_at = relative_to.to_owned() - TimeDelta::days(90); @@ -129,14 +133,33 @@ impl<'a> Channels<'a> { Ok(()) } + + pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { + let mut tx = self.db.begin().await?; + tx.channels().recanonicalize().await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] pub enum CreateError { #[error("channel named {0} already exists")] - DuplicateName(String), + DuplicateName(Name), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<LoadError> for CreateError { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } #[derive(Debug, thiserror::Error)] @@ -147,4 +170,32 @@ pub enum Error { Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<LoadError> for Error { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ExpireError { + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<LoadError> for ExpireError { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 27d35f0..e26ac2b 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -1,9 +1,12 @@ +use futures::stream::{StreamExt as _, TryStreamExt as _}; use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ channel::{Channel, History, Id}, clock::DateTime, + db::NotFound, event::{Instant, ResumePoint, Sequence}, + name::{self, Name}, }; pub trait Provider { @@ -19,134 +22,162 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Channels<'t>(&'t mut SqliteConnection); impl<'c> Channels<'c> { - pub async fn create(&mut self, name: &str, created: &Instant) -> Result<History, sqlx::Error> { + pub async fn create(&mut self, name: &Name, created: &Instant) -> Result<History, sqlx::Error> { let id = Id::generate(); - let channel = sqlx::query!( + let name = name.clone(); + let display_name = name.display(); + let canonical_name = name.canonical(); + let created = *created; + + sqlx::query!( r#" insert - into channel (id, name, created_at, created_sequence) - values ($1, $2, $3, $4) - returning - id as "id: Id", - name as "name!", -- known non-null as we just set it - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + into channel (id, created_at, created_sequence) + values ($1, $2, $3) "#, id, - name, created.at, created.sequence, ) - .map(|row| History { + .execute(&mut *self.0) + .await?; + + sqlx::query!( + r#" + insert into channel_name (id, display_name, canonical_name) + values ($1, $2, $3) + "#, + id, + display_name, + canonical_name, + ) + .execute(&mut *self.0) + .await?; + + let channel = History { channel: Channel { - id: row.id, - name: row.name, + id, + name: name.clone(), deleted_at: None, }, - created: Instant::new(row.created_at, row.created_sequence), + created, deleted: None, - }) - .fetch_one(&mut *self.0) - .await?; + }; Ok(channel) } - pub async fn by_id(&mut self, channel: &Id) -> Result<History, sqlx::Error> { + pub async fn by_id(&mut self, channel: &Id) -> Result<History, LoadError> { let channel = sqlx::query!( r#" select id as "id: Id", - channel.name, + name.display_name as "display_name?: String", + name.canonical_name as "canonical_name?: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where id = $1 "#, channel, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) .fetch_one(&mut *self.0) - .await?; + .await??; Ok(channel) } - pub async fn all(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, sqlx::Error> { + pub async fn all(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { let channels = sqlx::query!( r#" select id as "id: Id", - channel.name, + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", - deleted.deleted_at as "deleted_at: DateTime", - deleted.deleted_sequence as "deleted_sequence: Sequence" + deleted.deleted_at as "deleted_at?: DateTime", + deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where coalesce(channel.created_sequence <= $1, true) - order by channel.name + order by name.canonical_name "#, resume_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) } - pub async fn replay( - &mut self, - resume_at: Option<Sequence>, - ) -> Result<Vec<History>, sqlx::Error> { + pub async fn replay(&mut self, resume_at: Option<Sequence>) -> Result<Vec<History>, LoadError> { let channels = sqlx::query!( r#" select id as "id: Id", - channel.name, + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", - deleted.deleted_at as "deleted_at: DateTime", - deleted.deleted_sequence as "deleted_sequence: Sequence" + deleted.deleted_at as "deleted_at?: DateTime", + deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where coalesce(channel.created_sequence > $1, true) "#, resume_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) @@ -156,19 +187,18 @@ impl<'c> Channels<'c> { &mut self, channel: &History, deleted: &Instant, - ) -> Result<History, sqlx::Error> { + ) -> Result<History, LoadError> { let id = channel.id(); - sqlx::query_scalar!( + sqlx::query!( r#" insert into channel_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) - returning 1 as "deleted: bool" "#, id, deleted.at, deleted.sequence, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; // Small social responsibility hack here: when a channel is deleted, its name is @@ -179,16 +209,14 @@ impl<'c> Channels<'c> { // This also avoids the need for a separate name reservation table to ensure // that live channels have unique names, since the `channel` table's name field // is unique over non-null values. - sqlx::query_scalar!( + sqlx::query!( r#" - update channel - set name = null + delete from channel_name where id = $1 - returning 1 as "updated: bool" "#, id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; let channel = self.by_id(id).await?; @@ -230,38 +258,98 @@ impl<'c> Channels<'c> { Ok(()) } - pub async fn expired(&mut self, expired_at: &DateTime) -> Result<Vec<History>, sqlx::Error> { + pub async fn expired(&mut self, expired_at: &DateTime) -> Result<Vec<History>, LoadError> { let channels = sqlx::query!( r#" select channel.id as "id: Id", - channel.name, + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) left join message + on channel.id = message.channel where channel.created_at < $1 and message.id is null and deleted.id is null "#, expired_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) } + + pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { + let channels = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String" + from channel_name + "#, + ) + .fetch_all(&mut *self.0) + .await?; + + for channel in channels { + let name = Name::from(channel.display_name); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + update channel_name + set canonical_name = $1 + where id = $2 + "#, + canonical_name, + channel.id, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } } diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs index b489a77..d0cae05 100644 --- a/src/channel/routes/channel/post.rs +++ b/src/channel/routes/channel/post.rs @@ -9,7 +9,7 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, login::Login, - message::{app::SendError, Message}, + message::{app::SendError, Body, Message}, }; pub async fn handler( @@ -29,7 +29,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub body: String, + pub body: Body, } #[derive(Debug)] diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index a05c312..9781dd7 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -10,6 +10,7 @@ use crate::{ clock::RequestedAt, error::Internal, login::Login, + name::Name, }; pub async fn handler( @@ -29,7 +30,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, } #[derive(Debug)] diff --git a/src/channel/snapshot.rs b/src/channel/snapshot.rs index 2b7d89a..129c0d6 100644 --- a/src/channel/snapshot.rs +++ b/src/channel/snapshot.rs @@ -2,12 +2,12 @@ use super::{ event::{Created, Event}, Id, }; -use crate::clock::DateTime; +use crate::{clock::DateTime, name::Name}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { pub id: Id, - pub name: String, + pub name: Name, #[serde(skip_serializing_if = "Option::is_none")] pub deleted_at: Option<DateTime>, } diff --git a/src/cli.rs b/src/cli/mod.rs index 0659851..c75ce2b 100644 --- a/src/cli.rs +++ b/src/cli/mod.rs @@ -22,6 +22,8 @@ use crate::{ ui, }; +pub mod recanonicalize; + /// Command-line entry point for running the `hi` server. /// /// This is intended to be used as a Clap [Parser], to capture command-line diff --git a/src/cli/recanonicalize.rs b/src/cli/recanonicalize.rs new file mode 100644 index 0000000..5f8a1db --- /dev/null +++ b/src/cli/recanonicalize.rs @@ -0,0 +1,86 @@ +use sqlx::sqlite::SqlitePool; + +use crate::{app::App, db}; + +/// Command-line entry point for repairing canonical names in the `hi` database. +/// This command may be necessary after an upgrade, if the canonical forms of +/// names has changed. It will re-calculate the canonical form of each name in +/// the database, based on its display form, and store the results back to the +/// database. +/// +/// This is intended to be used as a Clap [Parser], to capture command-line +/// arguments for the `hi-recanonicalize` command: +/// +/// ```no_run +/// # use hi::recanonicalize::cli::Error; +/// # +/// # #[tokio::main] +/// # async fn main() -> Result<(), Error> { +/// use clap::Parser; +/// use hi::cli::recanonicalize::Args; +/// +/// let args = Args::parse(); +/// args.run().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(clap::Parser)] +#[command( + version, + about = "Recanonicalize names in the `hi` database.", + long_about = r#"Recanonicalize names in the `hi` database. + +The `hi` server must not be running while this command is run. + +The database at `--database-url` will also be created, or upgraded, automatically."# +)] +pub struct Args { + /// Sqlite URL or path for the `hi` database + #[arg(short, long, env, default_value = "sqlite://.hi")] + database_url: String, + + /// Sqlite URL or path for a backup of the `hi` database during upgrades + #[arg(short = 'D', long, env, default_value = "sqlite://.hi.backup")] + backup_database_url: String, +} + +impl Args { + /// Recanonicalizes the `hi` database, using the parsed configuation in + /// `self`. + /// + /// This will perform the following tasks: + /// + /// * Migrate the `hi` database (at `--database-url`). + /// * Recanonicalize names in the `login` and `channel` tables. + /// + /// # Errors + /// + /// Will return `Err` if the canonicalization or database upgrade processes + /// fail. The specific [`Error`] variant will expose the cause + /// of the failure. + pub async fn run(self) -> Result<(), Error> { + let pool = self.pool().await?; + + let app = App::from(pool); + app.logins().recanonicalize().await?; + app.channels().recanonicalize().await?; + + Ok(()) + } + + async fn pool(&self) -> Result<SqlitePool, db::Error> { + db::prepare(&self.database_url, &self.backup_database_url).await + } +} + +/// Errors that can be raised by [`Args::run`]. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + // /// Failure due to `io::Error`. See [`io::Error`]. + // Io(#[from] io::Error), + /// Failure due to a database initialization error. See [`db::Error`]. + Database(#[from] db::Error), + /// Failure due to a data manipulation error. See [`sqlx::Error`]. + Sqlx(#[from] sqlx::Error), +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 6005813..e0522d4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -130,14 +130,17 @@ pub enum Error { Rejected(String, String), } -pub trait NotFound { +pub trait NotFound: Sized { type Ok; type Error; fn not_found<E, F>(self, map: F) -> Result<Self::Ok, E> where E: From<Self::Error>, - F: FnOnce() -> E; + F: FnOnce() -> E, + { + self.optional()?.ok_or_else(map) + } fn optional(self) -> Result<Option<Self::Ok>, Self::Error>; } @@ -153,14 +156,6 @@ impl<T> NotFound for Result<T, sqlx::Error> { Err(other) => Err(other), } } - - fn not_found<E, F>(self, map: F) -> Result<T, E> - where - E: From<sqlx::Error>, - F: FnOnce() -> E, - { - self.optional()?.ok_or_else(map) - } } pub trait Duplicate { diff --git a/src/event/app.rs b/src/event/app.rs index 951ce25..c754388 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -11,6 +11,7 @@ use crate::{ channel::{self, repo::Provider as _}, login::{self, repo::Provider as _}, message::{self, repo::Provider as _}, + name, }; pub struct Events<'a> { @@ -26,7 +27,7 @@ impl<'a> Events<'a> { pub async fn subscribe( &self, resume_at: impl Into<ResumePoint>, - ) -> Result<impl Stream<Item = Event> + std::fmt::Debug, sqlx::Error> { + ) -> Result<impl Stream<Item = Event> + std::fmt::Debug, Error> { let resume_at = resume_at.into(); // Subscribe before retrieving, to catch messages broadcast while we're // querying the DB. We'll prune out duplicates later. @@ -81,3 +82,30 @@ impl<'a> Events<'a> { move |event| future::ready(filter(event)) } } + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl From<login::repo::LoadError> for Error { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +impl From<channel::repo::LoadError> for Error { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} diff --git a/src/event/routes/get.rs b/src/event/routes/get.rs index 357845a..22e8762 100644 --- a/src/event/routes/get.rs +++ b/src/event/routes/get.rs @@ -12,7 +12,7 @@ use futures::stream::{Stream, StreamExt as _}; use crate::{ app::App, error::{Internal, Unauthorized}, - event::{extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, + event::{app, extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, token::{app::ValidateError, extract::Identity}, }; @@ -69,7 +69,7 @@ impl TryFrom<Event> for sse::Event { #[derive(Debug, thiserror::Error)] #[error(transparent)] pub enum Error { - Database(#[from] sqlx::Error), + Subscribe(#[from] app::Error), Validate(#[from] ValidateError), } diff --git a/src/invite/app.rs b/src/invite/app.rs index ee7f74f..64ba753 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -7,6 +7,7 @@ use crate::{ db::{Duplicate as _, NotFound as _}, event::repo::Provider as _, login::{repo::Provider as _, Login, Password}, + name::Name, token::{repo::Provider as _, Secret}, }; @@ -42,7 +43,7 @@ impl<'a> Invites<'a> { pub async fn accept( &self, invite: &Id, - name: &str, + name: &Name, password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { @@ -68,7 +69,7 @@ impl<'a> Invites<'a> { .logins() .create(name, &password_hash, &created) .await - .duplicate(|| AcceptError::DuplicateLogin(name.into()))?; + .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; let secret = tx.tokens().issue(&login, accepted_at).await?; tx.commit().await?; @@ -92,7 +93,7 @@ pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), #[error("name in use: {0}")] - DuplicateLogin(String), + DuplicateLogin(Name), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/invite/mod.rs b/src/invite/mod.rs index abf1c3a..d59fb9c 100644 --- a/src/invite/mod.rs +++ b/src/invite/mod.rs @@ -3,7 +3,7 @@ mod id; mod repo; mod routes; -use crate::{clock::DateTime, login}; +use crate::{clock::DateTime, login, normalize::nfc}; pub use self::{id::Id, routes::router}; @@ -17,6 +17,6 @@ pub struct Invite { #[derive(serde::Serialize)] pub struct Summary { pub id: Id, - pub issuer: String, + pub issuer: nfc::String, pub issued_at: DateTime, } diff --git a/src/invite/repo.rs b/src/invite/repo.rs index 643f5b7..02f4e42 100644 --- a/src/invite/repo.rs +++ b/src/invite/repo.rs @@ -4,6 +4,7 @@ use super::{Id, Invite, Summary}; use crate::{ clock::DateTime, login::{self, Login}, + normalize::nfc, }; pub trait Provider { @@ -70,7 +71,7 @@ impl<'c> Invites<'c> { select invite.id as "invite_id: Id", issuer.id as "issuer_id: login::Id", - issuer.name as "issuer_name", + issuer.display_name as "issuer_name: nfc::String", invite.issued_at as "invite_issued_at: DateTime" from invite join login as issuer on (invite.issuer = issuer.id) diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index c072929..a41207a 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -10,6 +10,7 @@ use crate::{ error::{Internal, NotFound}, invite::app, login::{Login, Password}, + name::Name, token::extract::IdentityToken, }; @@ -31,7 +32,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } @@ -16,6 +16,8 @@ mod id; mod invite; mod login; mod message; +mod name; +mod normalize; mod setup; #[cfg(test)] mod test; diff --git a/src/login/app.rs b/src/login/app.rs index b6f7e1c..2f5896f 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,24 +1,37 @@ use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Login, Password}; +use super::repo::Provider as _; + +#[cfg(test)] +use super::{Login, Password}; +#[cfg(test)] use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, + name::Name, }; pub struct Logins<'a> { db: &'a SqlitePool, + #[cfg(test)] events: &'a Broadcaster, } impl<'a> Logins<'a> { + #[cfg(not(test))] + pub const fn new(db: &'a SqlitePool) -> Self { + Self { db } + } + + #[cfg(test)] pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { Self { db, events } } + #[cfg(test)] pub async fn create( &self, - name: &str, + name: &Name, password: &Password, created_at: &DateTime, ) -> Result<Login, CreateError> { @@ -34,6 +47,14 @@ impl<'a> Logins<'a> { Ok(login.as_created()) } + + pub async fn recanonicalize(&self) -> Result<(), sqlx::Error> { + let mut tx = self.db.begin().await?; + tx.logins().recanonicalize().await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] diff --git a/src/login/mod.rs b/src/login/mod.rs index 98cc3d7..64a3698 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,4 +1,3 @@ -#[cfg(test)] pub mod app; pub mod event; pub mod extract; diff --git a/src/login/password.rs b/src/login/password.rs index 14fd981..c27c950 100644 --- a/src/login/password.rs +++ b/src/login/password.rs @@ -4,6 +4,8 @@ use argon2::Argon2; use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use rand_core::OsRng; +use crate::normalize::nfc; + #[derive(sqlx::Type)] #[sqlx(transparent)] pub struct StoredHash(String); @@ -31,7 +33,7 @@ impl fmt::Debug for StoredHash { #[derive(serde::Deserialize)] #[serde(transparent)] -pub struct Password(String); +pub struct Password(nfc::String); impl Password { pub fn hash(&self) -> Result<StoredHash, password_hash::Error> { @@ -56,9 +58,8 @@ impl fmt::Debug for Password { } } -#[cfg(test)] impl From<String> for Password { fn from(password: String) -> Self { - Self(password) + Password(password.into()) } } diff --git a/src/login/repo.rs b/src/login/repo.rs index 7d0fcb1..c6bc734 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -1,9 +1,11 @@ +use futures::stream::{StreamExt as _, TryStreamExt as _}; use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, event::{Instant, ResumePoint, Sequence}, login::{password::StoredHash, History, Id, Login}, + name::{self, Name}, }; pub trait Provider { @@ -21,48 +23,48 @@ pub struct Logins<'t>(&'t mut SqliteConnection); impl<'c> Logins<'c> { pub async fn create( &mut self, - name: &str, + name: &Name, password_hash: &StoredHash, created: &Instant, ) -> Result<History, sqlx::Error> { let id = Id::generate(); + let display_name = name.display(); + let canonical_name = name.canonical(); - let login = sqlx::query!( + sqlx::query!( r#" insert - into login (id, name, password_hash, created_sequence, created_at) - values ($1, $2, $3, $4, $5) - returning - id as "id: Id", - name, - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" + into login (id, display_name, canonical_name, password_hash, created_sequence, created_at) + values ($1, $2, $3, $4, $5, $6) "#, id, - name, + display_name, + canonical_name, password_hash, created.sequence, created.at, ) - .map(|row| History { + .execute(&mut *self.0) + .await?; + + let login = History { + created: *created, login: Login { - id: row.id, - name: row.name, + id, + name: name.clone(), }, - created: Instant::new(row.created_at, row.created_sequence), - }) - .fetch_one(&mut *self.0) - .await?; + }; Ok(login) } - pub async fn all(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, sqlx::Error> { - let channels = sqlx::query!( + pub async fn all(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { + let logins = sqlx::query!( r#" select id as "id: Id", - name, + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login @@ -71,24 +73,30 @@ impl<'c> Logins<'c> { "#, resume_at, ) - .map(|row| History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), + .map(|row| { + Ok::<_, LoadError>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| res?) + .try_collect() .await?; - Ok(channels) + Ok(logins) } - pub async fn replay(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, sqlx::Error> { - let messages = sqlx::query!( + + pub async fn replay(&mut self, resume_at: ResumePoint) -> Result<Vec<History>, LoadError> { + let logins = sqlx::query!( r#" select id as "id: Id", - name, + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login @@ -96,22 +104,59 @@ impl<'c> Logins<'c> { "#, resume_at, ) - .map(|row| History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) }) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() + .await?; + + Ok(logins) + } + + pub async fn recanonicalize(&mut self) -> Result<(), sqlx::Error> { + let logins = sqlx::query!( + r#" + select + id as "id: Id", + display_name as "display_name: String" + from login + "#, + ) .fetch_all(&mut *self.0) .await?; - Ok(messages) + for login in logins { + let name = Name::from(login.display_name); + let canonical_name = name.canonical(); + + sqlx::query!( + r#" + update login + set canonical_name = $1 + where id = $2 + "#, + canonical_name, + login.id, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) } } -impl<'t> From<&'t mut SqliteConnection> for Logins<'t> { - fn from(tx: &'t mut SqliteConnection) -> Self { - Self(tx) - } +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), } diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs index 67eaa6d..20430db 100644 --- a/src/login/routes/login/post.rs +++ b/src/login/routes/login/post.rs @@ -9,6 +9,7 @@ use crate::{ clock::RequestedAt, error::Internal, login::{Login, Password}, + name::Name, token::{app, extract::IdentityToken}, }; @@ -29,7 +30,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } diff --git a/src/login/routes/logout/test.rs b/src/login/routes/logout/test.rs index 0e70e4c..91837fe 100644 --- a/src/login/routes/logout/test.rs +++ b/src/login/routes/logout/test.rs @@ -33,7 +33,6 @@ async fn successful() { assert_eq!(StatusCode::NO_CONTENT, response_status); // Verify the semantics - let error = app .tokens() .validate(&secret, &now) diff --git a/src/login/snapshot.rs b/src/login/snapshot.rs index 1a92f5c..e1eb96c 100644 --- a/src/login/snapshot.rs +++ b/src/login/snapshot.rs @@ -2,6 +2,7 @@ use super::{ event::{Created, Event}, Id, }; +use crate::name::Name; // This also implements FromRequestParts (see `./extract.rs`). As a result, it // can be used as an extractor for endpoints that want to require login, or for @@ -10,7 +11,7 @@ use super::{ #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Login { pub id: Id, - pub name: String, + pub name: Name, // The omission of the hashed password is deliberate, to minimize the // chance that it ends up tangled up in debug output or in some other chunk // of logic elsewhere. diff --git a/src/message/app.rs b/src/message/app.rs index 4e50513..852b958 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -2,13 +2,14 @@ use chrono::TimeDelta; use itertools::Itertools; use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Id, Message}; +use super::{repo::Provider as _, Body, Id, Message}; use crate::{ channel::{self, repo::Provider as _}, clock::DateTime, db::NotFound as _, event::{repo::Provider as _, Broadcaster, Event, Sequence}, login::Login, + name, }; pub struct Messages<'a> { @@ -26,7 +27,7 @@ impl<'a> Messages<'a> { channel: &channel::Id, sender: &Login, sent_at: &DateTime, - body: &str, + body: &Body, ) -> Result<Message, SendError> { let mut tx = self.db.begin().await?; let channel = tx @@ -119,6 +120,18 @@ pub enum SendError { ChannelNotFound(channel::Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<channel::repo::LoadError> for SendError { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } #[derive(Debug, thiserror::Error)] diff --git a/src/message/body.rs b/src/message/body.rs new file mode 100644 index 0000000..6dd224c --- /dev/null +++ b/src/message/body.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use crate::normalize::nfc; + +#[derive( + Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type, +)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Body(nfc::String); + +impl fmt::Display for Body { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(body) = self; + body.fmt(f) + } +} + +impl From<String> for Body { + fn from(body: String) -> Self { + Self(body.into()) + } +} + +impl From<Body> for String { + fn from(body: Body) -> Self { + let Body(body) = body; + body.into() + } +} diff --git a/src/message/mod.rs b/src/message/mod.rs index a8f51ab..c2687bc 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1,4 +1,5 @@ pub mod app; +mod body; pub mod event; mod history; mod id; @@ -6,4 +7,6 @@ pub mod repo; mod routes; mod snapshot; -pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Message}; +pub use self::{ + body::Body, event::Event, history::History, id::Id, routes::router, snapshot::Message, +}; diff --git a/src/message/repo.rs b/src/message/repo.rs index 85a69fc..4cfefec 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -1,6 +1,6 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; -use super::{snapshot::Message, History, Id}; +use super::{snapshot::Message, Body, History, Id}; use crate::{ channel, clock::DateTime, @@ -26,24 +26,24 @@ impl<'c> Messages<'c> { channel: &channel::History, sender: &Login, sent: &Instant, - body: &str, + body: &Body, ) -> Result<History, sqlx::Error> { let id = Id::generate(); let channel_id = channel.id(); let message = sqlx::query!( r#" - insert into message - (id, channel, sender, sent_at, sent_sequence, body) - values ($1, $2, $3, $4, $5, $6) - returning - id as "id: Id", + insert into message + (id, channel, sender, sent_at, sent_sequence, body) + values ($1, $2, $3, $4, $5, $6) + returning + id as "id: Id", channel as "channel: channel::Id", sender as "sender: login::Id", sent_at as "sent_at: DateTime", sent_sequence as "sent_sequence: Sequence", - body - "#, + body as "body: Body" + "#, id, channel_id, sender.id, @@ -76,7 +76,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -113,7 +113,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -150,7 +150,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", @@ -256,7 +256,7 @@ impl<'c> Messages<'c> { message.sender as "sender: login::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", - message.body, + message.body as "body: Body", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from message @@ -293,7 +293,7 @@ impl<'c> Messages<'c> { message.sender as "sender: login::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", - message.body, + message.body as "body: Body", deleted.deleted_at as "deleted_at: DateTime", deleted.deleted_sequence as "deleted_sequence: Sequence" from message diff --git a/src/message/snapshot.rs b/src/message/snapshot.rs index 7300918..53b7176 100644 --- a/src/message/snapshot.rs +++ b/src/message/snapshot.rs @@ -1,6 +1,6 @@ use super::{ event::{Event, Sent}, - Id, + Body, Id, }; use crate::{channel, clock::DateTime, event::Instant, login}; @@ -11,7 +11,7 @@ pub struct Message { pub channel: channel::Id, pub sender: login::Id, pub id: Id, - pub body: String, + pub body: Body, #[serde(skip_serializing_if = "Option::is_none")] pub deleted_at: Option<DateTime>, } diff --git a/src/name.rs b/src/name.rs new file mode 100644 index 0000000..9187d33 --- /dev/null +++ b/src/name.rs @@ -0,0 +1,85 @@ +use std::fmt; + +use crate::normalize::{ident, nfc}; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type)] +#[serde(from = "String", into = "String")] +pub struct Name { + display: nfc::String, + canonical: ident::String, +} + +impl Name { + pub fn new<D, C>(display: D, canonical: C) -> Result<Self, Error> + where + D: AsRef<str>, + C: AsRef<str>, + { + let name = Self::from(display); + + if name.canonical.as_str() == canonical.as_ref() { + Ok(name) + } else { + Err(Error::CanonicalMismatch( + canonical.as_ref().into(), + name.canonical, + name.display, + )) + } + } + + pub fn optional<D, C>(display: Option<D>, canonical: Option<C>) -> Result<Option<Self>, Error> + where + D: AsRef<str>, + C: AsRef<str>, + { + display + .zip(canonical) + .map(|(display, canonical)| Self::new(display, canonical)) + .transpose() + } + + pub fn display(&self) -> &nfc::String { + &self.display + } + + pub fn canonical(&self) -> &ident::String { + &self.canonical + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("stored canonical form {0:#?} does not match computed canonical form {:#?} for name {:#?}", .1.as_str(), .2.as_str())] + CanonicalMismatch(String, ident::String, nfc::String), +} + +impl Default for Name { + fn default() -> Self { + Self::from(String::default()) + } +} + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.display.fmt(f) + } +} + +impl<S> From<S> for Name +where + S: AsRef<str>, +{ + fn from(name: S) -> Self { + let display = nfc::String::from(&name); + let canonical = ident::String::from(&name); + + Self { display, canonical } + } +} + +impl From<Name> for String { + fn from(name: Name) -> Self { + name.display.into() + } +} diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs new file mode 100644 index 0000000..6294201 --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,36 @@ +mod string; + +pub mod nfc { + use std::string::String as StdString; + + use unicode_normalization::UnicodeNormalization as _; + + pub type String = super::string::String<Nfc>; + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + pub struct Nfc; + + impl super::string::Normalize for Nfc { + fn normalize(&self, value: &str) -> StdString { + value.nfc().collect() + } + } +} + +pub mod ident { + use std::string::String as StdString; + + use unicode_casefold::UnicodeCaseFold as _; + use unicode_normalization::UnicodeNormalization as _; + + pub type String = super::string::String<Ident>; + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + pub struct Ident; + + impl super::string::Normalize for Ident { + fn normalize(&self, value: &str) -> StdString { + value.case_fold().nfkc().collect() + } + } +} diff --git a/src/normalize/string.rs b/src/normalize/string.rs new file mode 100644 index 0000000..a0d178c --- /dev/null +++ b/src/normalize/string.rs @@ -0,0 +1,112 @@ +use std::{fmt, string::String as StdString}; + +use sqlx::{ + encode::{Encode, IsNull}, + Database, Decode, Type, +}; + +pub trait Normalize: Clone + Default { + fn normalize(&self, value: &str) -> StdString; +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(into = "StdString", from = "StdString")] +#[serde(bound = "N: Normalize")] +pub struct String<N>(StdString, N); + +impl<N> fmt::Display for String<N> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(value, _) = self; + value.fmt(f) + } +} + +impl<S, N> From<S> for String<N> +where + S: AsRef<str>, + N: Normalize, +{ + fn from(value: S) -> Self { + let normalizer = N::default(); + let value = normalizer.normalize(value.as_ref()); + + Self(value, normalizer) + } +} + +impl<N> From<String<N>> for StdString { + fn from(value: String<N>) -> Self { + let String(value, _) = value; + value + } +} + +impl<N> std::ops::Deref for String<N> { + type Target = StdString; + + fn deref(&self) -> &Self::Target { + let Self(value, _) = self; + value + } +} + +// Type is manually implemented so that we can implement Decode to do +// normalization on read. Implementation is otherwise based on +// `#[derive(sqlx::Type)]` with the `#[sqlx(transparent)]` attribute. +impl<DB, N> Type<DB> for String<N> +where + DB: Database, + StdString: Type<DB>, +{ + fn type_info() -> <DB as Database>::TypeInfo { + <StdString as Type<DB>>::type_info() + } + + fn compatible(ty: &<DB as Database>::TypeInfo) -> bool { + <StdString as Type<DB>>::compatible(ty) + } +} + +impl<'r, DB, N> Decode<'r, DB> for String<N> +where + DB: Database, + StdString: Decode<'r, DB>, + N: Normalize, +{ + fn decode(value: <DB as Database>::ValueRef<'r>) -> Result<Self, sqlx::error::BoxDynError> { + let value = StdString::decode(value)?; + Ok(Self::from(value)) + } +} + +impl<'q, DB, N> Encode<'q, DB> for String<N> +where + DB: Database, + StdString: Encode<'q, DB>, +{ + fn encode_by_ref( + &self, + buf: &mut <DB as Database>::ArgumentBuffer<'q>, + ) -> Result<IsNull, sqlx::error::BoxDynError> { + let Self(value, _) = self; + value.encode_by_ref(buf) + } + + fn encode( + self, + buf: &mut <DB as Database>::ArgumentBuffer<'q>, + ) -> Result<IsNull, sqlx::error::BoxDynError> { + let Self(value, _) = self; + value.encode(buf) + } + + fn produces(&self) -> Option<<DB as Database>::TypeInfo> { + let Self(value, _) = self; + value.produces() + } + + fn size_hint(&self) -> usize { + let Self(value, _) = self; + value.size_hint() + } +} diff --git a/src/setup/app.rs b/src/setup/app.rs index d015813..030b5f6 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -5,6 +5,7 @@ use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, login::{repo::Provider as _, Login, Password}, + name::Name, token::{repo::Provider as _, Secret}, }; @@ -20,7 +21,7 @@ impl<'a> Setup<'a> { pub async fn initial( &self, - name: &str, + name: &Name, password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index 34f4ed2..fb2280a 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -9,6 +9,7 @@ use crate::{ clock::RequestedAt, error::Internal, login::{Login, Password}, + name::Name, setup::app, token::extract::IdentityToken, }; @@ -30,7 +31,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index a1dda61..3831c82 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -11,6 +11,7 @@ use crate::{ channel::{self, Channel}, clock::RequestedAt, event::Event, + name::Name, }; pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { @@ -21,13 +22,13 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { .expect("should always succeed if the channel is actually new") } -pub fn propose() -> String { - rand::random::<Name>().to_string() +pub fn propose() -> Name { + rand::random::<NameTemplate>().to_string().into() } -struct Name(String); +struct NameTemplate(String); faker_impl_from_templates! { - Name; "{} {}", CityName, FullName; + NameTemplate; "{} {}", CityName, FullName; } pub fn events(event: Event) -> future::Ready<Option<channel::Event>> { diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index b6766fe..714b936 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -5,6 +5,7 @@ use crate::{ app::App, clock::RequestedAt, login::{self, Login, Password}, + name::Name, }; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login, Password) { @@ -29,16 +30,16 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Login { pub fn fictitious() -> Login { Login { id: login::Id::generate(), - name: name(), + name: propose_name(), } } -pub fn propose() -> (String, Password) { - (name(), propose_password()) +pub fn propose() -> (Name, Password) { + (propose_name(), propose_password()) } -fn name() -> String { - rand::random::<internet::Username>().to_string() +fn propose_name() -> Name { + rand::random::<internet::Username>().to_string().into() } pub fn propose_password() -> Password { diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index eb00e7c..c450bce 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -8,7 +8,7 @@ use crate::{ clock::RequestedAt, event::Event, login::Login, - message::{self, Message}, + message::{self, Body, Message}, }; pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &RequestedAt) -> Message { @@ -20,8 +20,8 @@ pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &Request .expect("should succeed if the channel exists") } -pub fn propose() -> String { - rand::random::<Paragraphs>().to_string() +pub fn propose() -> Body { + rand::random::<Paragraphs>().to_string().into() } pub fn events(event: Event) -> future::Ready<Option<message::Event>> { diff --git a/src/token/app.rs b/src/token/app.rs index 0dc1a46..c19d6a0 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -7,12 +7,14 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - repo::auth::Provider as _, repo::Provider as _, Broadcaster, Event as TokenEvent, Id, Secret, + repo::{self, auth::Provider as _, Provider as _}, + Broadcaster, Event as TokenEvent, Id, Secret, }; use crate::{ clock::DateTime, db::NotFound as _, login::{Login, Password}, + name::{self, Name}, }; pub struct Tokens<'a> { @@ -27,7 +29,7 @@ impl<'a> Tokens<'a> { pub async fn login( &self, - name: &str, + name: &Name, password: &Password, login_at: &DateTime, ) -> Result<(Login, Secret), LoginError> { @@ -65,14 +67,16 @@ impl<'a> Tokens<'a> { used_at: &DateTime, ) -> Result<(Id, Login), ValidateError> { let mut tx = self.db.begin().await?; - let login = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - Ok(login) + let login = login.as_snapshot().ok_or(ValidateError::LoginDeleted)?; + + Ok((token, login)) } pub async fn limit_stream<E>( @@ -162,15 +166,40 @@ pub enum LoginError { #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] + Name(#[from] name::Error), + #[error(transparent)] PasswordHash(#[from] password_hash::Error), } +impl From<repo::auth::LoadError> for LoginError { + fn from(error: repo::auth::LoadError) -> Self { + use repo::auth::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, + #[error("login deleted")] + LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From<repo::LoadError> for ValidateError { + fn from(error: repo::LoadError) -> Self { + match error { + repo::LoadError::Database(error) => error.into(), + repo::LoadError::Name(error) => error.into(), + } + } } #[derive(Debug)] diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs index 88d0878..bdc4c33 100644 --- a/src/token/repo/auth.rs +++ b/src/token/repo/auth.rs @@ -2,8 +2,10 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, + db::NotFound, event::{Instant, Sequence}, login::{self, password::StoredHash, History, Login}, + name::{self, Name}, }; pub trait Provider { @@ -19,35 +21,53 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Auth<'t>(&'t mut SqliteConnection); impl<'t> Auth<'t> { - pub async fn for_name(&mut self, name: &str) -> Result<(History, StoredHash), sqlx::Error> { - let found = sqlx::query!( + pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> { + let name = name.canonical(); + let row = sqlx::query!( r#" - select - id as "id: login::Id", - name, - password_hash as "password_hash: StoredHash", + select + id as "id: login::Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" - from login - where name = $1 - "#, + created_at as "created_at: DateTime", + password_hash as "password_hash: StoredHash" + from login + where canonical_name = $1 + "#, name, ) - .map(|row| { - ( - History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), - }, - row.password_hash, - ) - }) .fetch_one(&mut *self.0) .await?; - Ok(found) + let login = History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }; + + Ok((login, row.password_hash)) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } } } diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index 9169743..d8463eb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,4 @@ pub mod auth; mod token; -pub use self::token::Provider; +pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index c592dcd..35ea385 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -3,7 +3,10 @@ use uuid::Uuid; use crate::{ clock::DateTime, + db::NotFound, + event::{Instant, Sequence}, login::{self, History, Login}, + name::{self, Name}, token::{Id, Secret}, }; @@ -100,53 +103,78 @@ impl<'c> Tokens<'c> { } // Validate a token by its secret, retrieving the associated Login record. - // Will return [None] if the token is not valid. The token's last-used - // timestamp will be set to `used_at`. + // Will return an error if the token is not valid. If successful, the + // retrieved token's last-used timestamp will be set to `used_at`. pub async fn validate( &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, Login), sqlx::Error> { + ) -> Result<(Id, History), LoadError> { // I would use `update … returning` to do this in one query, but // sqlite3, as of this writing, does not allow an update's `returning` // clause to reference columns from tables joined into the update. Two // queries is fine, but it feels untidy. - sqlx::query!( + let (token, login) = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 + returning + id as "token: Id", + login as "login: login::Id" "#, used_at, secret, ) - .execute(&mut *self.0) + .map(|row| (row.token, row.login)) + .fetch_one(&mut *self.0) .await?; let login = sqlx::query!( r#" select - token.id as "token_id: Id", - login.id as "login_id: login::Id", - login.name as "login_name" + id as "id: login::Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", + created_sequence as "created_sequence: Sequence", + created_at as "created_at: DateTime" from login - join token on login.id = token.login - where token.secret = $1 + where id = $1 "#, - secret, + login, ) .map(|row| { - ( - row.token_id, - Login { - id: row.login_id, - name: row.login_name, + Ok::<_, name::Error>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }, - ) + created: Instant::new(row.created_at, row.created_sequence), + }) }) .fetch_one(&mut *self.0) - .await?; + .await??; + + Ok((token, login)) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl<T> NotFound for Result<T, LoadError> { + type Ok = T; + type Error = LoadError; - Ok(login) + fn optional(self) -> Result<Option<T>, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } } } |
