diff options
| author | ojacobson <ojacobson@noreply.codeberg.org> | 2025-07-04 05:00:21 +0200 |
|---|---|---|
| committer | ojacobson <ojacobson@noreply.codeberg.org> | 2025-07-04 05:00:21 +0200 |
| commit | c35be3ae29e77983f013c01260dda20208175f2b (patch) | |
| tree | abf0b9d993ef03a53903aae03f375b78473952da | |
| parent | 981cd3c0f4cf912c1d91ee5d9c39f5c1aa7afecf (diff) | |
| parent | 9b38cb1a62ede4900fde4ba47a7b065db329e994 (diff) | |
Rename "channels" to "conversations."
The term "channel" for a conversational container has a long and storied history, but is mostly evocative of IRC and of other, ah, "nerd-centric" services. It does show up in more widespread contexts: Discord and Slack both refer to their primary conversational containers as "channels," for example. However, I think it's unnecessary jargon, and I'd like to do away with it.
To that end, this change pervasively changes one term to the other wherever it appears, with the following exceptions:
* A `channel` concept (unrelated to conversations) is also provided by an external library; we can't and shouldn't try to rename that.
* The code to deal with the `pilcrow:channelData` and `pilcrow:lastActiveChannel` local storage properties is still present, to migrate existing data to new keys. It will be removed in a later change.
This is a **breaking API change**. As we are not yet managing any API compatibility promises, this is formally not an issue, but it is something to be aware of practically. The major API changes are:
* Paths beginning with `/api/channels` are now under `/api/conversations`, without other modifications.
* Fields labelled with `channel…` terms are now labelled with `conversation…` terms. For example, a `message` `sent` event is now sent to a `conversation`, not a `channel`.
This is also a **breaking UI change**. Specifically, any saved paths for `/ch/CHANNELID` will now lead to a 404. The corresponding paths are `/c/CONVERSATIONID`. While I've made an effort to migrate the location of stored data, I have not tried to provide adapters to fix this specific issue, because the disruption is short-lived and very easily addressed by opening a channel in the client UI.
This change is obnoxiously large and difficult to review, for which I apologize. If this shows up in `git annotate`, please forgive me. These kinds of renamings are hard to carry out without a major disruption, especially when the concept ("channel" in this case) is used so pervasively throughout the system.
I think it's worth making this change that pervasively so that we don't have an indefinitely-long tail of "well, it's a conversation in the docs, but the table is called `channel` for historical reasons" type issues.
Merges conversations-not-channels into main.
95 files changed, 1471 insertions, 1280 deletions
diff --git a/.sqlx/query-0e06b7b5aeb29b946ab79430dd7a65ab18430e7f71afde9af52e99767c1f268f.json b/.sqlx/query-0e06b7b5aeb29b946ab79430dd7a65ab18430e7f71afde9af52e99767c1f268f.json new file mode 100644 index 0000000..befefa1 --- /dev/null +++ b/.sqlx/query-0e06b7b5aeb29b946ab79430dd7a65ab18430e7f71afde9af52e99767c1f268f.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n with has_messages as (\n select conversation\n from message\n group by conversation\n )\n delete from conversation_deleted\n where deleted_at < $1\n and id not in has_messages\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "0e06b7b5aeb29b946ab79430dd7a65ab18430e7f71afde9af52e99767c1f268f" +} diff --git a/.sqlx/query-1f0f35655dd57532897aaba9bde38547e626387dfe5b859f02ae1dbe171d5741.json b/.sqlx/query-1f0f35655dd57532897aaba9bde38547e626387dfe5b859f02ae1dbe171d5741.json new file mode 100644 index 0000000..9c62ca9 --- /dev/null +++ b/.sqlx/query-1f0f35655dd57532897aaba9bde38547e626387dfe5b859f02ae1dbe171d5741.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into conversation (id, created_at, created_sequence, last_sequence)\n values ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "1f0f35655dd57532897aaba9bde38547e626387dfe5b859f02ae1dbe171d5741" +} diff --git a/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json b/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json deleted file mode 100644 index 2de9614..0000000 --- a/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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-3ff8a089ca1c57111e8c0e8d6d9da73e88a6cab35ae511674d25aa78bac9bc04.json b/.sqlx/query-3aa6bb5450cfc6b4b1acfc7723b166dc49bfc1449fc3551faa376e303136a988.json index ad364ea..bcbb09c 100644 --- a/.sqlx/query-3ff8a089ca1c57111e8c0e8d6d9da73e88a6cab35ae511674d25aa78bac9bc04.json +++ b/.sqlx/query-3aa6bb5450cfc6b4b1acfc7723b166dc49bfc1449fc3551faa376e303136a988.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: user::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.last_sequence > $1\n ", + "query": "\n select\n id as \"id: Id\",\n message.conversation as \"conversation: conversation::Id\",\n message.sender as \"sender: user::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.last_sequence > $1\n ", "describe": { "columns": [ { @@ -9,7 +9,7 @@ "type_info": "Text" }, { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 1, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "3ff8a089ca1c57111e8c0e8d6d9da73e88a6cab35ae511674d25aa78bac9bc04" + "hash": "3aa6bb5450cfc6b4b1acfc7723b166dc49bfc1449fc3551faa376e303136a988" } diff --git a/.sqlx/query-17f6f507d9343a734e1098f6ce56d372edeb35f92769a0181a71d68a68780649.json b/.sqlx/query-3ea2e910657ae0045eba8ed3c9d359549114abf305116ecbc046d53ea4382966.json index 0e67b03..cd2c808 100644 --- a/.sqlx/query-17f6f507d9343a734e1098f6ce56d372edeb35f92769a0181a71d68a68780649.json +++ b/.sqlx/query-3ea2e910657ae0045eba8ed3c9d359549114abf305116ecbc046d53ea4382966.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: user::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 ", + "query": "\n select\n message.conversation as \"conversation: conversation::Id\",\n message.sender as \"sender: user::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.conversation = $1\n and deleted.id is null\n ", "describe": { "columns": [ { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 0, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "17f6f507d9343a734e1098f6ce56d372edeb35f92769a0181a71d68a68780649" + "hash": "3ea2e910657ae0045eba8ed3c9d359549114abf305116ecbc046d53ea4382966" } diff --git a/.sqlx/query-579bf0557d3e141dfd411c25c2ae66d6abb70f7bd2413cfbe23b71d1ce6090cd.json b/.sqlx/query-427a530f68282ba586c1e2d980b7f8cbc8a8339377814ca5f889860da99b1561.json index 27349d7..82db559 100644 --- a/.sqlx/query-579bf0557d3e141dfd411c25c2ae66d6abb70f7bd2413cfbe23b71d1ce6090cd.json +++ b/.sqlx/query-427a530f68282ba586c1e2d980b7f8cbc8a8339377814ca5f889860da99b1561.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n insert into message\n (id, channel, sender, sent_at, sent_sequence, body, last_sequence)\n values ($1, $2, $3, $4, $5, $6, $7)\n returning\n id as \"id: Id\",\n channel as \"channel: channel::Id\",\n sender as \"sender: user::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n body as \"body: Body\"\n ", + "query": "\n insert into message\n (id, conversation, sender, sent_at, sent_sequence, body, last_sequence)\n values ($1, $2, $3, $4, $5, $6, $7)\n returning\n id as \"id: Id\",\n conversation as \"conversation: conversation::Id\",\n sender as \"sender: user::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n body as \"body: Body\"\n ", "describe": { "columns": [ { @@ -9,7 +9,7 @@ "type_info": "Text" }, { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 1, "type_info": "Text" }, @@ -46,5 +46,5 @@ true ] }, - "hash": "579bf0557d3e141dfd411c25c2ae66d6abb70f7bd2413cfbe23b71d1ce6090cd" + "hash": "427a530f68282ba586c1e2d980b7f8cbc8a8339377814ca5f889860da99b1561" } diff --git a/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json b/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json deleted file mode 100644 index ebddeb8..0000000 --- a/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json b/.sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json deleted file mode 100644 index c919eb2..0000000 --- a/.sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n with has_messages as (\n select channel\n from message\n group by channel\n )\n delete from channel_deleted\n where deleted_at < $1\n and id not in has_messages\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990" -} diff --git a/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json b/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json deleted file mode 100644 index 8962098..0000000 --- a/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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-4e39f27605dec811824fddae5559dda60c4b2a9c6746376a3552ce73b7d8ea38.json b/.sqlx/query-4e39f27605dec811824fddae5559dda60c4b2a9c6746376a3552ce73b7d8ea38.json deleted file mode 100644 index 902b216..0000000 --- a/.sqlx/query-4e39f27605dec811824fddae5559dda60c4b2a9c6746376a3552ce73b7d8ea38.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update channel\n set last_sequence = max(last_sequence, $1)\n where id = $2\n returning id as \"id: Id\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false - ] - }, - "hash": "4e39f27605dec811824fddae5559dda60c4b2a9c6746376a3552ce73b7d8ea38" -} diff --git a/.sqlx/query-b18432e78891ffca0d7f3fcd1c543db4a3f02c211462704f7810fdbed924ac30.json b/.sqlx/query-508cc72b2bdc712e66c43643914eceed46ca25ec1d9c91127c4134ccbd471aa0.json index 3ae7605..a95038e 100644 --- a/.sqlx/query-b18432e78891ffca0d7f3fcd1c543db4a3f02c211462704f7810fdbed924ac30.json +++ b/.sqlx/query-508cc72b2bdc712e66c43643914eceed46ca25ec1d9c91127c4134ccbd471aa0.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: user::Id\",\n message.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.sent_sequence <= $1\n order by message.sent_sequence\n ", + "query": "\n select\n message.conversation as \"conversation: conversation::Id\",\n message.sender as \"sender: user::Id\",\n message.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.sent_sequence <= $1\n order by message.sent_sequence\n ", "describe": { "columns": [ { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 0, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "b18432e78891ffca0d7f3fcd1c543db4a3f02c211462704f7810fdbed924ac30" + "hash": "508cc72b2bdc712e66c43643914eceed46ca25ec1d9c91127c4134ccbd471aa0" } diff --git a/.sqlx/query-fb7114754c6dc8ffe623ae0f3f61ec2e20795692db7019d962378c740ae69599.json b/.sqlx/query-5d25f783816c1b10750e2de3c07e457b5b21805257cd117f7d1833491ee41312.json index 7f1e1f3..f2a9f36 100644 --- a/.sqlx/query-fb7114754c6dc8ffe623ae0f3f61ec2e20795692db7019d962378c740ae69599.json +++ b/.sqlx/query-5d25f783816c1b10750e2de3c07e457b5b21805257cd117f7d1833491ee41312.json @@ -1,10 +1,10 @@ { "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: user::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 ", + "query": "\n select\n message.conversation as \"conversation: conversation::Id\",\n message.sender as \"sender: user::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": [ { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 0, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "fb7114754c6dc8ffe623ae0f3f61ec2e20795692db7019d962378c740ae69599" + "hash": "5d25f783816c1b10750e2de3c07e457b5b21805257cd117f7d1833491ee41312" } diff --git a/.sqlx/query-5ec47dd232a277d37e2f1a523cc4aadf757f55c6f44836e9c37b36e473ac2321.json b/.sqlx/query-5ec47dd232a277d37e2f1a523cc4aadf757f55c6f44836e9c37b36e473ac2321.json new file mode 100644 index 0000000..aa3166d --- /dev/null +++ b/.sqlx/query-5ec47dd232a277d37e2f1a523cc4aadf757f55c6f44836e9c37b36e473ac2321.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into conversation_name (id, display_name, canonical_name)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "5ec47dd232a277d37e2f1a523cc4aadf757f55c6f44836e9c37b36e473ac2321" +} diff --git a/.sqlx/query-66da54683caf3b003d20427290da9266cbd53de8761ea9ea58c4311ba78677a9.json b/.sqlx/query-66da54683caf3b003d20427290da9266cbd53de8761ea9ea58c4311ba78677a9.json new file mode 100644 index 0000000..2869b91 --- /dev/null +++ b/.sqlx/query-66da54683caf3b003d20427290da9266cbd53de8761ea9ea58c4311ba78677a9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into conversation_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "66da54683caf3b003d20427290da9266cbd53de8761ea9ea58c4311ba78677a9" +} diff --git a/.sqlx/query-6a767f2ad017fe3f56c4eb6060ea34dba0d4d325bc07fc882bea05abbae5414c.json b/.sqlx/query-6a767f2ad017fe3f56c4eb6060ea34dba0d4d325bc07fc882bea05abbae5414c.json new file mode 100644 index 0000000..e15da37 --- /dev/null +++ b/.sqlx/query-6a767f2ad017fe3f56c4eb6060ea34dba0d4d325bc07fc882bea05abbae5414c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from conversation_name\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6a767f2ad017fe3f56c4eb6060ea34dba0d4d325bc07fc882bea05abbae5414c" +} diff --git a/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json b/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json deleted file mode 100644 index 4bcf6d0..0000000 --- a/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete from channel\n where id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803" -} diff --git a/.sqlx/query-85145c8b8264e7d01eef66e22353037f33fd3a3eaec0e36f06ffbbffb625aa24.json b/.sqlx/query-85145c8b8264e7d01eef66e22353037f33fd3a3eaec0e36f06ffbbffb625aa24.json new file mode 100644 index 0000000..9d212fa --- /dev/null +++ b/.sqlx/query-85145c8b8264e7d01eef66e22353037f33fd3a3eaec0e36f06ffbbffb625aa24.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n update conversation\n set last_sequence = max(last_sequence, $1)\n where id = $2\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "85145c8b8264e7d01eef66e22353037f33fd3a3eaec0e36f06ffbbffb625aa24" +} diff --git a/.sqlx/query-c44dbcc7f4c0257a991e1ae4a2679aaa4c3f28aa5436a9af067a754e46af5589.json b/.sqlx/query-927cc51fad82650938c68b8da423da82470585807b45622535cbe83cc35a10e8.json index 37d685a..4d67f21 100644 --- a/.sqlx/query-c44dbcc7f4c0257a991e1ae4a2679aaa4c3f28aa5436a9af067a754e46af5589.json +++ b/.sqlx/query-927cc51fad82650938c68b8da423da82470585807b45622535cbe83cc35a10e8.json @@ -1,6 +1,6 @@ { "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 channel.last_sequence > $1\n ", + "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 conversation.created_at as \"created_at: DateTime\",\n conversation.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from conversation\n left join conversation_name as name\n using (id)\n left join conversation_deleted as deleted\n using (id)\n where id = $1\n ", "describe": { "columns": [ { @@ -52,5 +52,5 @@ false ] }, - "hash": "c44dbcc7f4c0257a991e1ae4a2679aaa4c3f28aa5436a9af067a754e46af5589" + "hash": "927cc51fad82650938c68b8da423da82470585807b45622535cbe83cc35a10e8" } diff --git a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json b/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json deleted file mode 100644 index e886759..0000000 --- a/.sqlx/query-a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "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, - false, - false, - false, - false, - false, - false - ] - }, - "hash": "a4b34593fdf71bb911beb850cfa88adb346b1770e32785166bd43cb853143a7f" -} diff --git a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json b/.sqlx/query-c960562be5b34678c305895cc4aaa6d96ad25184042ea1f989499285d41400f8.json index 6700c43..427962a 100644 --- a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json +++ b/.sqlx/query-c960562be5b34678c305895cc4aaa6d96ad25184042ea1f989499285d41400f8.json @@ -1,6 +1,6 @@ { "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 ", + "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 conversation.created_at as \"created_at: DateTime\",\n conversation.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from conversation\n left join conversation_name as name\n using (id)\n left join conversation_deleted as deleted\n using (id)\n where conversation.last_sequence > $1\n ", "describe": { "columns": [ { @@ -52,5 +52,5 @@ false ] }, - "hash": "e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e" + "hash": "c960562be5b34678c305895cc4aaa6d96ad25184042ea1f989499285d41400f8" } diff --git a/.sqlx/query-ca9146e92c3b3e724f4b58ad72529de7030a4863d3bf479bb19a6a2a76d1590b.json b/.sqlx/query-ca9146e92c3b3e724f4b58ad72529de7030a4863d3bf479bb19a6a2a76d1590b.json deleted file mode 100644 index 0118249..0000000 --- a/.sqlx/query-ca9146e92c3b3e724f4b58ad72529de7030a4863d3bf479bb19a6a2a76d1590b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into channel (id, created_at, created_sequence, last_sequence)\n values ($1, $2, $3, $4)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "ca9146e92c3b3e724f4b58ad72529de7030a4863d3bf479bb19a6a2a76d1590b" -} diff --git a/.sqlx/query-cf24c0d42c7e5bdc16a7925544d2057667955e468d1b6683a89d45d6ce58166f.json b/.sqlx/query-cf24c0d42c7e5bdc16a7925544d2057667955e468d1b6683a89d45d6ce58166f.json new file mode 100644 index 0000000..c32961b --- /dev/null +++ b/.sqlx/query-cf24c0d42c7e5bdc16a7925544d2057667955e468d1b6683a89d45d6ce58166f.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n conversation.id as \"id: Id\",\n name.display_name as \"display_name?: String\",\n name.canonical_name as \"canonical_name?: String\",\n conversation.created_at as \"created_at: DateTime\",\n conversation.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from conversation\n left join conversation_name as name\n using (id)\n left join conversation_deleted as deleted\n using (id)\n left join message\n on conversation.id = message.conversation\n where conversation.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, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "cf24c0d42c7e5bdc16a7925544d2057667955e468d1b6683a89d45d6ce58166f" +} diff --git a/.sqlx/query-dfb54b7d586cc99b9e03cb08727b5af8d0b95fc30797dadcc0eb98f12667f14e.json b/.sqlx/query-dfb54b7d586cc99b9e03cb08727b5af8d0b95fc30797dadcc0eb98f12667f14e.json new file mode 100644 index 0000000..bfbdef6 --- /dev/null +++ b/.sqlx/query-dfb54b7d586cc99b9e03cb08727b5af8d0b95fc30797dadcc0eb98f12667f14e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from conversation\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "dfb54b7d586cc99b9e03cb08727b5af8d0b95fc30797dadcc0eb98f12667f14e" +} diff --git a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json b/.sqlx/query-e2cc4ef6af56e6ac1fcf2353fb52cdf99a84c160cf1fc2bd1e889c4c6fff59ea.json index ce757ba..97dde44 100644 --- a/.sqlx/query-ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb.json +++ b/.sqlx/query-e2cc4ef6af56e6ac1fcf2353fb52cdf99a84c160cf1fc2bd1e889c4c6fff59ea.json @@ -1,6 +1,6 @@ { "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 channel.created_sequence <= $1\n order by name.canonical_name\n ", + "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 conversation.created_at as \"created_at: DateTime\",\n conversation.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from conversation\n left join conversation_name as name\n using (id)\n left join conversation_deleted as deleted\n using (id)\n where conversation.created_sequence <= $1\n order by name.canonical_name\n ", "describe": { "columns": [ { @@ -52,5 +52,5 @@ false ] }, - "hash": "ad1485f0a5514bcfaf68477723ae0f13e93a1f18213e208d3a181936a36da5fb" + "hash": "e2cc4ef6af56e6ac1fcf2353fb52cdf99a84c160cf1fc2bd1e889c4c6fff59ea" } diff --git a/.sqlx/query-3d0354407ac4dcd7b012328b9086c4f582bd2b3dc99a6a8e5f4853e0d4c43c50.json b/.sqlx/query-f13957b52b93cf6a1c1bd85f016148eb69fd5ee077567bbc0dd49acf69a3b2b2.json index 0481b7b..04298cc 100644 --- a/.sqlx/query-3d0354407ac4dcd7b012328b9086c4f582bd2b3dc99a6a8e5f4853e0d4c43c50.json +++ b/.sqlx/query-f13957b52b93cf6a1c1bd85f016148eb69fd5ee077567bbc0dd49acf69a3b2b2.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: user::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 ", + "query": "\n select\n id as \"id: Id\",\n message.conversation as \"conversation: conversation::Id\",\n message.sender as \"sender: user::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": [ { @@ -9,7 +9,7 @@ "type_info": "Text" }, { - "name": "channel: channel::Id", + "name": "conversation: conversation::Id", "ordinal": 1, "type_info": "Text" }, @@ -58,5 +58,5 @@ false ] }, - "hash": "3d0354407ac4dcd7b012328b9086c4f582bd2b3dc99a6a8e5f4853e0d4c43c50" + "hash": "f13957b52b93cf6a1c1bd85f016148eb69fd5ee077567bbc0dd49acf69a3b2b2" } diff --git a/docs/api/SUMMARY.md b/docs/api/SUMMARY.md index 99b6352..f51fbc7 100644 --- a/docs/api/SUMMARY.md +++ b/docs/api/SUMMARY.md @@ -6,4 +6,4 @@ - [Client initialization](boot.md) - [Events](events.md) - [Invitations](invitations.md) -- [Channels and messages](channels-messages.md) +- [Conversations and messages](conversations-messages.md) diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 2e9b58f..fbd5959 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -17,7 +17,7 @@ To create users, see [initial setup](./initial-setup.md) and [invitations](./inv ## Names -<!-- This prose is duplicated in channels-messages.md. If you change it here, consider changing it there, too. --> +<!-- This prose is duplicated in conversations-messages.md. If you change it here, consider changing it there, too. --> The service handles user names using two separate forms. diff --git a/docs/api/boot.md b/docs/api/boot.md index f6e6dc2..d7e9144 100644 --- a/docs/api/boot.md +++ b/docs/api/boot.md @@ -52,7 +52,7 @@ This endpoint will respond with a status of "name": "example username" }, { - "type": "channel", + "type": "conversation", "event": "created", "at": "2025-04-14T23:58:11.421901Z", "id": "C1234abcd", @@ -62,7 +62,7 @@ This endpoint will respond with a status of "type": "message", "event": "sent", "at": "2024-09-27T23:19:10.208147Z", - "channel": "C1234abcd", + "conversation": "C1234abcd", "sender": "U1234abcd", "id": "M1312acab", "body": "beep" @@ -71,13 +71,13 @@ This endpoint will respond with a status of "type": "message", "event": "sent", "at": "2025-06-19T15:14:40.431627Z", - "channel": "Ccfdryfdb4krpy77", + "conversation": "Ccfdryfdb4krpy77", "sender": "U888j6fyc8ccrnkf", "id": "Mc6jk823wjc82734", "body": "test" }, { - "type": "channel", + "type": "conversation", "event": "created", "at": "2025-06-19T15:14:44.764263Z", "id": "C2d9y6wckph3n36x", @@ -87,7 +87,7 @@ This endpoint will respond with a status of "type": "message", "event": "sent", "at": "2025-06-19T15:29:47.376455Z", - "channel": "Ccfdryfdb4krpy77", + "conversation": "Ccfdryfdb4krpy77", "sender": "U888j6fyc8ccrnkf", "id": "M3twnj7rfk2ph744", "body": "test" diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md deleted file mode 100644 index e762600..0000000 --- a/docs/api/channels-messages.md +++ /dev/null @@ -1,235 +0,0 @@ -# Channels and messages - -```mermaid ---- -Channel lifecycle ---- -stateDiagram-v2 - [*] --> Active : POST /api/channels - Active --> Deleted : DELETE /api/channels/C1234 - Active --> Deleted : Expiry - Deleted --> [*] : Purge -``` - -```mermaid ---- -Message lifecycle ---- -stateDiagram-v2 - [*] --> Sent : POST /api/channels/C1234 - Sent --> Deleted : DELETE /api/messages/Mabcd - Sent --> Deleted : Expiry - Deleted --> [*] : Purge -``` - -Messages allow logins to communicate with one another. Channels are the conversations to which those messages are sent. - -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. - -Deleted channels and messages, including those that have expired, are temporarily retained by the service, to allow clients that are not connected to receive the corresponding deletion [events](./events.md). To limit storage growth, deleted channels and messages are purged from the service seven days after they were deleted. - -## `POST /api/channels` - -Creates a channel. - -### Request - -```json -{ - "name": "a unique channel name" -} -``` - -The request must have the following fields: - -| Field | Type | Description | -| :----- | :----- | :------------------ | -| `name` | string | The channel's name. | - -The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: - -- It must be non-empty. -- It must not be "too long." (Currently, 64 characters is too long.) -- It must begin with a printing character. -- It must end with a printing character. -- It must not contain runs of multiple whitespace characters. - -### Success - -This endpoint will respond with a status of -`202 Accepted` when successful. The body of the response will be a JSON object describing the new channel: - -```json -{ - "id": "C9876cyyz", - "name": "a unique channel name" -} -``` - -The response will have the following fields: - -| Field | Type | Description | -| :----- | :----- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| `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. - -### Name not valid - -This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. - -### Channel name in use - -This endpoint will respond with a status of `409 Conflict` if a channel with the requested name already exists. - -## `POST /api/channels/:id` - -Sends a message to a channel. - -This endpoint requires the following path parameter: - -| Parameter | Type | Description | -| :-------- | :----- | :------------ | -| `id` | string | A channel ID. | - -### Request - -```json -{ - "body": "my amazing thoughts, by bob" -} -``` - -The request must have the following fields: - -| Field | Type | Description | -| :----- | :----- | :------------------------------------- | -| `body` | string | The message to deliver to the channel. | - -### Success - -This endpoint will respond with a status of -`202 Accepted` when successful. The body of the response will be a JSON object describing the newly-sent message: - -```json -{ - "at": "2024-10-19T04:37:09.467325Z", - "channel": "Cfqdn1234", - "sender": "Uabcd1234", - "id": "Mgh98yp75", - "body": "my amazing thoughts, by bob" -} -``` - -The response will have the following fields: - -| Field | Type | Description | -| :-------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| `at` | timestamp | The moment the message was sent. | -| `channel` | string | The ID of the channel the message was sent to. | -| `sender` | string | The ID of the user that sent the message. | -| `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 - -This endpoint will respond with a status of `404 Not Found` if the channel ID is not valid. - -## `DELETE /api/channels/:id` - -Deletes a channel. - -Deleting a channel prevents it from receiving any further messages. The channel must be empty; to delete a channel with messages in it, delete the messages first (or wait for them to expire). - -This endpoint requires the following path parameter: - -| Parameter | Type | Description | -| :-------- | :----- | :------------ | -| `id` | string | A channel ID. | - -### Success - -This endpoint will respond with a status of -`202 Accepted` when successful. The body of the response will be a JSON object describing the deleted channel: - -```json -{ - "id": "Cfqdn1234" -} -``` - -The response will have the following fields: - -| Field | Type | Description | -| :---- | :----- | :---------------- | -| `id` | string | The channel's ID. | - -When completed, the service will emit a [message deleted](events.md#message-deleted) event for each message in the channel, followed by a [channel deleted](events.md#channel-deleted) event with the channel's ID. - -### Channel not empty - -This endpoint will respond with a status of `409 Conflict` if the channel contains messages. - -### Invalid channel ID - -This endpoint will respond with a status of `404 Not Found` if the channel ID is not valid. - -## `DELETE /api/messages/:id` - -Deletes a message. - -This endpoint requires the following path parameter: - -| Parameter | Type | Description | -| :-------- | :----- | :------------ | -| `id` | string | A message ID. | - -### Success - -This endpoint will respond with a status of -`202 Accepted` when successful. The body of the response will be a JSON object describing the deleted message: - -```json -{ - "id": "Mgh98yp75" -} -``` - -The response will have the following fields: - -| Field | Type | Description | -| :---- | :----- | :---------------- | -| `id` | string | The message's ID. | - -When completed, the service will emit a [message deleted](events.md#message-deleted) event with the message's ID. - -### Invalid message ID - -This endpoint will respond with a status of `404 Not Found` if the message ID is not valid. - -### Not the sender - -This endpoint will respond with a status of `403 Forbidden` if the message was sent by a different login. diff --git a/docs/api/conversations-messages.md b/docs/api/conversations-messages.md new file mode 100644 index 0000000..c7995f7 --- /dev/null +++ b/docs/api/conversations-messages.md @@ -0,0 +1,231 @@ +# Conversations and messages + +```mermaid +--- +Conversation lifecycle +--- +stateDiagram-v2 + [*] --> Active : POST /api/conversations + Active --> Deleted : DELETE /api/conversations/C1234 + Active --> Deleted : Expiry + Deleted --> [*] : Purge +``` + +```mermaid +--- +Message lifecycle +--- +stateDiagram-v2 + [*] --> Sent : POST /api/conversations/C1234 + Sent --> Deleted : DELETE /api/messages/Mabcd + Sent --> Deleted : Expiry + Deleted --> [*] : Purge +``` + +Messages allow logins to communicate with one another. Conversations are the containers to which those messages are sent. + +Every conversation has a unique name, chosen when the conversation is created. + +## Names + +<!-- This prose is duplicated in authentication.md. If you change it here, consider changing it there, too. --> + +The service handles conversation names using two separate forms. + +The first form is as given in the request used to create the conversation. This form of the conversation 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 conversations. 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" conversation. + +## Expiry and purging + +Both conversations and messages expire after a time. Messages sent to a conversation will keep the conversation from expiring until the messages also expire. + +Deleted conversations and messages, including those that have expired, are temporarily retained by the service, to allow clients that are not connected to receive the corresponding deletion [events](./events.md). To limit storage growth, deleted conversations and messages are purged from the service seven days after they were deleted. + +## `POST /api/conversations` + +Creates a conversation. + +### Request + +```json +{ + "name": "a unique conversation name" +} +``` + +The request must have the following fields: + +| Field | Type | Description | +| :----- | :----- | :----------------------- | +| `name` | string | The conversation's name. | + +The proposed `name` must be valid. The precise definition of valid is still up in the air, but, at minimum: + +- It must be non-empty. +- It must not be "too long." (Currently, 64 characters is too long.) +- It must begin with a printing character. +- It must end with a printing character. +- It must not contain runs of multiple whitespace characters. + +### Success + +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the new conversation: + +```json +{ + "id": "C9876cyyz", + "name": "a unique conversation name" +} +``` + +The response will have the following fields: + +| Field | Type | Description | +| :----- | :----- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | string | A unique identifier for the conversation. This can be used to associate the conversation with events, or to make API calls targeting the conversation. | +| `name` | string | The conversation's normalized 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 [conversation created](events.md#conversation-created) event with the conversation's ID. + +### Name not valid + +This endpoint will respond with a status of `400 Bad Request` if the proposed `name` is not valid. + +### Conversation name in use + +This endpoint will respond with a status of `409 Conflict` if a conversation with the requested name already exists. + +## `POST /api/conversations/:id` + +Sends a message to a conversation. + +This endpoint requires the following path parameter: + +| Parameter | Type | Description | +| :-------- | :----- | :----------------- | +| `id` | string | A conversation ID. | + +### Request + +```json +{ + "body": "my amazing thoughts, by bob" +} +``` + +The request must have the following fields: + +| Field | Type | Description | +| :----- | :----- | :------------------------------------------ | +| `body` | string | The message to deliver to the conversation. | + +### Success + +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the newly-sent message: + +```json +{ + "at": "2024-10-19T04:37:09.467325Z", + "conversation": "Cfqdn1234", + "sender": "Uabcd1234", + "id": "Mgh98yp75", + "body": "my amazing thoughts, by bob" +} +``` + +The response will have the following fields: + +| Field | Type | Description | +| :------------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| `at` | timestamp | The moment the message was sent. | +| `conversation` | string | The ID of the conversation the message was sent to. | +| `sender` | string | The ID of the user that sent the message. | +| `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 normalized 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 conversation ID + +This endpoint will respond with a status of `404 Not Found` if the conversation ID is not valid. + +## `DELETE /api/conversations/:id` + +Deletes a conversation. + +Deleting a conversation prevents it from receiving any further messages. The conversation must be empty; to delete a conversation with messages in it, delete the messages first (or wait for them to expire). + +This endpoint requires the following path parameter: + +| Parameter | Type | Description | +| :-------- | :----- | :----------------- | +| `id` | string | A conversation ID. | + +### Success + +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the deleted conversation: + +```json +{ + "id": "Cfqdn1234" +} +``` + +The response will have the following fields: + +| Field | Type | Description | +| :---- | :----- | :--------------------- | +| `id` | string | The conversation's ID. | + +When completed, the service will emit a [message deleted](events.md#message-deleted) event for each message in the conversation, followed by a [conversation deleted](events.md#conversation-deleted) event with the conversation's ID. + +### Conversation not empty + +This endpoint will respond with a status of `409 Conflict` if the conversation contains messages. + +### Invalid conversation ID + +This endpoint will respond with a status of `404 Not Found` if the conversation ID is not valid. + +## `DELETE /api/messages/:id` + +Deletes a message. + +This endpoint requires the following path parameter: + +| Parameter | Type | Description | +| :-------- | :----- | :------------ | +| `id` | string | A message ID. | + +### Success + +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the deleted message: + +```json +{ + "id": "Mgh98yp75" +} +``` + +The response will have the following fields: + +| Field | Type | Description | +| :---- | :----- | :---------------- | +| `id` | string | The message's ID. | + +When completed, the service will emit a [message deleted](events.md#message-deleted) event with the message's ID. + +### Invalid message ID + +This endpoint will respond with a status of `404 Not Found` if the message ID is not valid. + +### Not the sender + +This endpoint will respond with a status of `403 Forbidden` if the message was sent by a different login. diff --git a/docs/api/events.md b/docs/api/events.md index 570dffa..e692f82 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -28,13 +28,11 @@ sequenceDiagram end ``` -The core of the service is to facilitate conversations between users. Conversational activity is delivered to clients using -_events_. Each event notifies interested clients of activity sent to the service through its API. +The core of the service is to facilitate conversations between users. Conversational activity is delivered to clients using _events_. Each event notifies interested clients of activity sent to the service through its API. ## Asynchronous completion -A number of endpoints return -`202 Accepted` responses. The actions performed by those endpoints will be completed before events are delivered. To await the completion of an operation which returns this response, clients must monitor the event stream for the corresponding event. +A number of endpoints return `202 Accepted` responses. The actions performed by those endpoints will be completed before events are delivered. To await the completion of an operation which returns this response, clients must monitor the event stream for the corresponding event. ## `GET /api/events` @@ -46,14 +44,11 @@ This endpoint is designed for use with the [EventSource] DOM API, and supports s ### Query parameters -This endpoint requires a -`resume_point` (integer) query parameter. The event stream will collect events published after that point in time. The value must be obtained by calling the [ -`GET /api/boot`](./boot.md) method. +This endpoint requires a `resume_point` (integer) query parameter. The event stream will collect events published after that point in time. The value must be obtained by calling the [`GET /api/boot`](./boot.md) method. ### Request headers -This endpoint accepts an optional `last-event-id` (string) header. If present, the value must be the value of the -`id` field of the last message processed by the client. The returned event stream will begin with the following message. If absent, the returned event stream will begin from the start of the event collection. +This endpoint accepts an optional `last-event-id` (string) header. If present, the value must be the value of the `id` field of the last message processed by the client. The returned event stream will begin with the following message. If absent, the returned event stream will begin from the start of the event collection. This header is set automatically by `EventSource` when reconnecting to an event stream. @@ -69,7 +64,7 @@ data: { data: "type": "message", data: "event": "sent", data: "at": "2024-09-27T23:19:10.208147Z", -data: "channel": "C9876cyyz", +data: "conversation": "C9876cyyz", data: "sender": "U1234abcd", data: "id": "M1312acab", data: "body": "beep" @@ -78,9 +73,7 @@ data: } The service will keep the connection open, and will deliver events as they occur. -The service may terminate the connection at any time. Clients should reconnect and resume the stream, using the -`last-event-id` header to resume from the last message received. The -`id` of each event is an ephemeral ID, useful only for this purpose. +The service may terminate the connection at any time. Clients should reconnect and resume the stream, using the `last-event-id` header to resume from the last message received. The `id` of each event is an ephemeral ID, useful only for this purpose. Each event's `data` consists of a JSON object describing one event. Every event includes the following fields: @@ -133,66 +126,63 @@ These events have the `event` field set to `"created"`. They include the followi | `id` | string | A unique identifier for the newly-created user. This can be used to associate the user with other events, or to make API calls targeting the user. | | `name` | string | The user's name. | -## Channel events +## Conversation events -The following events describe changes to [channels](./channels-messages.md). +The following events describe changes to [conversations](conversations-messages.md). -These events have the `type` field set to `"channel"`. +These events have the `type` field set to `"conversation"`. -### Channel created +### Conversation created ```json { - "type": "channel", + "type": "conversation", "event": "created", "at": "2024-09-27T23:18:10.208147Z", "id": "C9876cyyz", - "name": "example channel 2" + "name": "example conversation 2" } ``` -Sent whenever a new channel is created. +Sent whenever a new conversation is created. These events have the `event` field set to `"created"`. They include the following additional fields: -| Field | Type | Description | -| :----------- | :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `at` | timestamp | The moment the channel was created. | -| `id` | string | A unique identifier for the newly-created channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | -| `name` | string | The channel's name. | -| `deleted_at` | timestamp, optional | If set, the moment the channel was deleted. | +| Field | Type | Description | +| :----------- | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `at` | timestamp | The moment the conversation was created. | +| `id` | string | A unique identifier for the newly-created conversation. This can be used to associate the conversation with other events, or to make API calls targeting the conversation. | +| `name` | string | The conversation's name. | +| `deleted_at` | timestamp, optional | If set, the moment the conversation was deleted. | -When a channel is deleted or expires, the `"created"` event is replaced with a tombstone -`"created"` event, so that the original channel cannot be trivially recovered from the event stream. Tombstone events have a -`deleted_at` field, and a `name` of `""`. Tombstone events for channels use an empty string as the name, and not -`null` or with the `name` field removed entirely, to simplify client development. While clients -_should_ treat deleted channels specially, for example by rendering them as "channel deleted" markers, they don't have to be - they make sense if interpreted as channels with empty names, too. +When a conversation is deleted or expires, the `"created"` event is replaced with a tombstone +`"created"` event, so that the original conversation cannot be trivially recovered from the event stream. Tombstone events have a `deleted_at` field, and a `name` of `""`. Tombstone events for conversation use an empty string as the name, and not `null` or with the `name` field removed entirely, to simplify client development. While clients _should_ treat deleted conversation specially, for example by rendering them as "conversation deleted" markers, they don't have to be - they make sense if interpreted as conversation with empty names, too. -Once a deleted channel is [purged](./channels-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. +Once a deleted conversation is [purged](conversations-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. -### Channel deleted +### Conversation deleted ```json { - "type": "channel", + "type": "conversation", "event": "deleted", "at": "2024-09-28T03:40:25.384318Z", "id": "C9876cyyz" } ``` -Sent whenever a channel is deleted or expires. +Sent whenever a conversation is deleted or expires. These events have the `event` field set to `"deleted"`. They include the following additional fields: -| Field | Type | Description | -| :---- | :-------- | :---------------------------------- | -| `at` | timestamp | The moment the channel was deleted. | -| `id` | string | The deleted channel's ID. | +| Field | Type | Description | +| :---- | :-------- | :--------------------------------------- | +| `at` | timestamp | The moment the conversation was deleted. | +| `id` | string | The deleted conversation's ID. | ## Message events -The following events describe changes to [messages](./channels-messages.md). +The following events describe changes to [messages](conversations-messages.md). These events have the `type` field set to `"message"`. @@ -203,7 +193,7 @@ These events have the `type` field set to `"message"`. "type": "message", "event": "sent", "at": "2024-09-27T23:19:10.208147Z", - "channel": "C9876cyyz", + "conversation": "C9876cyyz", "sender": "U1234abcd", "id": "M1312acab", "body": "an effusive blob of prose, condensed down to a single string" @@ -214,22 +204,19 @@ Sent whenever a message is sent by a client. These events have the `event` field set to `"sent"`. They include the following additional fields: -| Field | Type | Description | -| :----------- | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------- | -| `at` | timestamp | The moment the message was sent. | -| `channel` | string | The ID of the channel the message was sent to. | -| `sender` | string | The ID of the user that sent the message. | -| `id` | string | A unique identifier for the message. This can be used to associate the message with other events, or to make API calls targeting the message. | -| `body` | string | The text of the message. | -| `deleted_at` | timestamp, optional | If set, the moment the message was deleted. | - -When a message is deleted or expires, the `"sent"` event is replaced with a tombstone -`"sent"` event, so that the original message cannot be trivially recovered from the event stream. Tombstone events have a -`deleted_at` field, and a `body` of `""`. Tombstone events for messages use an empty string as the `body`, and not -`null` or with the `body` field removed entirely, to simplify client development. While clients +| Field | Type | Description | +| :------------- | :------------------ | :-------------------------------------------------------------------------------------------------------------------------------------------- | +| `at` | timestamp | The moment the message was sent. | +| `conversation` | string | The ID of the conversation the message was sent to. | +| `sender` | string | The ID of the user that sent the message. | +| `id` | string | A unique identifier for the message. This can be used to associate the message with other events, or to make API calls targeting the message. | +| `body` | string | The text of the message. | +| `deleted_at` | timestamp, optional | If set, the moment the message was deleted. | + +When a message is deleted or expires, the `"sent"` event is replaced with a tombstone `"sent"` event, so that the original message cannot be trivially recovered from the event stream. Tombstone events have a `deleted_at` field, and a `body` of `""`. Tombstone events for messages use an empty string as the `body`, and not `null` or with the `body` field removed entirely, to simplify client development. While clients _should_ treat deleted messages specially, for example by rendering them as "message deleted" markers, they don't have to be - they make sense if interpreted as messages with empty bodies, too. -Once a deleted message is [purged](./channels-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. +Once a deleted message is [purged](conversations-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. ### Message deleted diff --git a/docs/developer/server/code-organization.md b/docs/developer/server/code-organization.md index d17b604..3a691a2 100644 --- a/docs/developer/server/code-organization.md +++ b/docs/developer/server/code-organization.md @@ -6,7 +6,7 @@ Trust your gut, and reorganize to meet new needs. We've already revised this sch ## Topic modules -High-level concerns are grouped into topical modules. These include `crate::channel`, `crate::events`, `crate::login`, and others. Those modules generally contain their own app types, their own repo types, their own extractors, and any other supporting code they need. They may also provide an interface to other modules in the program. +High-level concerns are grouped into topical modules. These include `crate::conversation`, `crate::events`, `crate::login`, and others. Those modules generally contain their own app types, their own repo types, their own extractors, and any other supporting code they need. They may also provide an interface to other modules in the program. Most topic modules contain one or more of: diff --git a/docs/developer/server/testing.md b/docs/developer/server/testing.md index 8e87568..a3109cb 100644 --- a/docs/developer/server/testing.md +++ b/docs/developer/server/testing.md @@ -22,6 +22,6 @@ Prefer writing "flat" fixtures that do one thing, over compound fixtures that do Prefer role-specific names for test values: use, for example, `sender` for a login related to sending messages, rather than `login`. Fixture data is cheap, so make as many entities as make sense for the test. They'll vanish at the end of the test anyways. -Prefer testing a single endpoint at a time. Other interactions, which may be needed to set up the scenario or verify the results, should be done against the `app` abstraction directly. It's okay if this leads to redundant tests (see for example `src/channel/routes/test/on_send.rs` and `src/events/routes/test.rs`, which overlap heavily). +Prefer testing a single endpoint at a time. Other interactions, which may be needed to set up the scenario or verify the results, should be done against the `app` abstraction directly. It's okay if this leads to redundant tests (see for example `src/conversation/routes/test/on_send.rs` and `src/events/routes/test.rs`, which overlap heavily). Panicking in tests is fine. Panic messages should describe why the preconditions were expected, and can be terse. diff --git a/migrations/20250624003106_rename_channel_to_conversation.sql b/migrations/20250624003106_rename_channel_to_conversation.sql new file mode 100644 index 0000000..753d42b --- /dev/null +++ b/migrations/20250624003106_rename_channel_to_conversation.sql @@ -0,0 +1,153 @@ +alter table message + rename to old_message; + +alter table message_deleted + rename to old_message_deleted; + +create table conversation ( + id text + not null + primary key, + created_sequence bigint + unique + not null, + created_at text + not null, + last_sequence bigint + not null +); + +insert into + conversation (id, created_sequence, created_at, last_sequence) +select + id, + created_sequence, + created_at, + last_sequence +from + channel; + +create table conversation_name ( + id text + not null + primary key + references conversation (id), + display_name + not null, + canonical_name + not null + unique +); + +insert into + conversation_name (id, display_name, canonical_name) +select + id, + display_name, + canonical_name +from + channel_name; + +create table conversation_deleted ( + id text + not null + primary key + references conversation (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +insert into + conversation_deleted (id, deleted_sequence, deleted_at) +select + id, + deleted_sequence, + deleted_at +from + channel_deleted; + +create table message ( + id text + not null + primary key, + conversation text + not null + references conversation (id), + sender text + not null + references user (id), + sent_sequence bigint + unique + not null, + sent_at text + not null, + body text + null, + last_sequence bigint + not null +); + +insert into + message (id, conversation, sender, sent_sequence, sent_at, body, last_sequence) +select + id, + channel, + sender, + sent_sequence, + sent_at, + body, + last_sequence +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 channel_deleted; +drop table channel_name; +drop table channel; + +create index conversation_created_sequence + on conversation (created_sequence); + +create index conversation_created_at + on conversation (created_at); + +create index conversation_last_sequence + on conversation (last_sequence); + +create index message_deleted_deleted_at + on message_deleted (deleted_at); + +create index message_sent_at + on message (sent_at); + +create index message_conversation + on message (conversation); + +create index message_last_sequence + on message (last_sequence); @@ -2,7 +2,7 @@ use sqlx::sqlite::SqlitePool; use crate::{ boot::app::Boot, - channel::app::Channels, + conversation::app::Conversations, event::{self, app::Events}, invite::app::Invites, message::app::Messages, @@ -37,8 +37,8 @@ impl App { Boot::new(&self.db) } - pub const fn channels(&self) -> Channels { - Channels::new(&self.db, &self.events) + pub const fn conversations(&self) -> Conversations { + Conversations::new(&self.db, &self.events) } pub const fn events(&self) -> Events { diff --git a/src/boot/app.rs b/src/boot/app.rs index 89eec12..0ed5d1b 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -3,7 +3,7 @@ use sqlx::sqlite::SqlitePool; use super::Snapshot; use crate::{ - channel::{self, repo::Provider as _}, + conversation::{self, repo::Provider as _}, event::{Event, Sequence, repo::Provider as _}, message::{self, repo::Provider as _}, name, @@ -24,7 +24,7 @@ impl<'a> Boot<'a> { let resume_point = tx.sequence().current().await?; let users = tx.users().all(resume_point).await?; - let channels = tx.channels().all(resume_point).await?; + let conversations = tx.conversations().all(resume_point).await?; let messages = tx.messages().all(resume_point).await?; tx.commit().await?; @@ -36,9 +36,9 @@ impl<'a> Boot<'a> { .filter(Sequence::up_to(resume_point)) .map(Event::from); - let channel_events = channels + let conversation_events = conversations .iter() - .map(channel::History::events) + .map(conversation::History::events) .kmerge_by(Sequence::merge) .filter(Sequence::up_to(resume_point)) .map(Event::from); @@ -51,7 +51,7 @@ impl<'a> Boot<'a> { .map(Event::from); let events = user_events - .merge_by(channel_events, Sequence::merge) + .merge_by(conversation_events, Sequence::merge) .merge_by(message_events, Sequence::merge) .collect(); @@ -79,9 +79,9 @@ impl From<user::repo::LoadError> for Error { } } -impl From<channel::repo::LoadError> for Error { - fn from(error: channel::repo::LoadError) -> Self { - use channel::repo::LoadError; +impl From<conversation::repo::LoadError> for Error { + fn from(error: conversation::repo::LoadError) -> Self { + use conversation::repo::LoadError; match error { LoadError::Name(error) => error.into(), LoadError::Database(error) => error.into(), diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs index 1e590a7..c7c511a 100644 --- a/src/boot/handlers/boot/test.rs +++ b/src/boot/handlers/boot/test.rs @@ -37,9 +37,9 @@ async fn includes_users() { } #[tokio::test] -async fn includes_channels() { +async fn includes_conversations() { let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let viewer = fixtures::identity::fictitious(); let response = super::handler(State(app), viewer) @@ -50,19 +50,19 @@ async fn includes_channels() { .snapshot .events .into_iter() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::created) .exactly_one() - .expect("only one channel has been created"); - assert_eq!(channel, created.channel); + .expect("only one conversation has been created"); + assert_eq!(conversation, created.conversation); } #[tokio::test] async fn includes_messages() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; let viewer = fixtures::identity::fictitious(); let response = super::handler(State(app), viewer) @@ -84,9 +84,9 @@ async fn includes_messages() { async fn includes_expired_messages() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let expired_message = - fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; app.messages() .expire(&fixtures::now()) @@ -126,8 +126,9 @@ async fn includes_expired_messages() { async fn includes_deleted_messages() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let deleted_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; + let deleted_message = + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; app.messages() .delete(&sender, &deleted_message.id, &fixtures::now()) @@ -164,11 +165,11 @@ async fn includes_deleted_messages() { } #[tokio::test] -async fn includes_expired_channels() { +async fn includes_expired_conversations() { let app = fixtures::scratch_app().await; - let expired_channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let expired_conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; - app.channels() + app.conversations() .expire(&fixtures::now()) .await .expect("expiry never fails"); @@ -183,34 +184,34 @@ async fn includes_expired_channels() { .events .iter() .cloned() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::created) .exactly_one() - .expect("only one channel has been created"); - // We don't expect `expired_channel` to match the event exactly, as the name will have been - // tombstoned and the channel given a `deleted_at` date. - assert_eq!(expired_channel.id, created.channel.id); + .expect("only one conversation has been created"); + // We don't expect `expired_conversation` to match the event exactly, as the name will + // have been tombstoned and the conversation given a `deleted_at` date. + assert_eq!(expired_conversation.id, created.conversation.id); let deleted = response .snapshot .events .into_iter() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::deleted) .exactly_one() - .expect("only one channel has expired"); - assert_eq!(expired_channel.id, deleted.id); + .expect("only one conversation has expired"); + assert_eq!(expired_conversation.id, deleted.id); } #[tokio::test] -async fn includes_deleted_channels() { +async fn includes_deleted_conversations() { let app = fixtures::scratch_app().await; - let deleted_channel = fixtures::channel::create(&app, &fixtures::now()).await; + let deleted_conversation = fixtures::conversation::create(&app, &fixtures::now()).await; - app.channels() - .delete(&deleted_channel.id, &fixtures::now()) + app.conversations() + .delete(&deleted_conversation.id, &fixtures::now()) .await - .expect("deleting a valid channel succeeds"); + .expect("deleting a valid conversation succeeds"); let viewer = fixtures::identity::fictitious(); let response = super::handler(State(app), viewer) @@ -222,21 +223,21 @@ async fn includes_deleted_channels() { .events .iter() .cloned() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::created) .exactly_one() - .expect("only one channel has been created"); - // We don't expect `deleted_channel` to match the event exactly, as the name will have been - // tombstoned and the channel given a `deleted_at` date. - assert_eq!(deleted_channel.id, created.channel.id); + .expect("only one conversation has been created"); + // We don't expect `deleted_conversation` to match the event exactly, as the name will + // have been tombstoned and the conversation given a `deleted_at` date. + assert_eq!(deleted_conversation.id, created.conversation.id); let deleted = response .snapshot .events .into_iter() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::deleted) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::deleted) .exactly_one() - .expect("only one channel has been deleted"); - assert_eq!(deleted_channel.id, deleted.id); + .expect("only one conversation has been deleted"); + assert_eq!(deleted_conversation.id, deleted.id); } diff --git a/src/channel/app.rs b/src/conversation/app.rs index e3b169c..81ccdcf 100644 --- a/src/channel/app.rs +++ b/src/conversation/app.rs @@ -3,7 +3,7 @@ use itertools::Itertools; use sqlx::sqlite::SqlitePool; use super::{ - Channel, Id, + Conversation, Id, repo::{LoadError, Provider as _}, validate, }; @@ -15,76 +15,88 @@ use crate::{ name::{self, Name}, }; -pub struct Channels<'a> { +pub struct Conversations<'a> { db: &'a SqlitePool, events: &'a Broadcaster, } -impl<'a> Channels<'a> { +impl<'a> Conversations<'a> { pub const fn new(db: &'a SqlitePool, events: &'a Broadcaster) -> Self { Self { db, events } } - pub async fn create(&self, name: &Name, created_at: &DateTime) -> Result<Channel, CreateError> { + pub async fn create( + &self, + name: &Name, + created_at: &DateTime, + ) -> Result<Conversation, CreateError> { if !validate::name(name) { return Err(CreateError::InvalidName(name.clone())); } let mut tx = self.db.begin().await?; let created = tx.sequence().next(created_at).await?; - let channel = tx - .channels() + let conversation = tx + .conversations() .create(name, &created) .await .duplicate(|| CreateError::DuplicateName(name.clone()))?; tx.commit().await?; self.events - .broadcast(channel.events().map(Event::from).collect::<Vec<_>>()); + .broadcast(conversation.events().map(Event::from).collect::<Vec<_>>()); - Ok(channel.as_created()) + Ok(conversation.as_created()) } - // 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<Channel, Error> { - let to_not_found = || Error::NotFound(channel.clone()); - let to_deleted = || Error::Deleted(channel.clone()); + // This function is careless with respect to time, and gets you the + // conversation as it exists in the specific moment when you call it. + pub async fn get(&self, conversation: &Id) -> Result<Conversation, Error> { + let to_not_found = || Error::NotFound(conversation.clone()); + let to_deleted = || Error::Deleted(conversation.clone()); let mut tx = self.db.begin().await?; - let channel = tx.channels().by_id(channel).await.not_found(to_not_found)?; + let conversation = tx + .conversations() + .by_id(conversation) + .await + .not_found(to_not_found)?; tx.commit().await?; - channel.as_snapshot().ok_or_else(to_deleted) + conversation.as_snapshot().ok_or_else(to_deleted) } - pub async fn delete(&self, channel: &Id, deleted_at: &DateTime) -> Result<(), DeleteError> { + pub async fn delete( + &self, + conversation: &Id, + deleted_at: &DateTime, + ) -> Result<(), DeleteError> { let mut tx = self.db.begin().await?; - let channel = tx - .channels() - .by_id(channel) + let conversation = tx + .conversations() + .by_id(conversation) .await - .not_found(|| DeleteError::NotFound(channel.clone()))?; - channel + .not_found(|| DeleteError::NotFound(conversation.clone()))?; + conversation .as_snapshot() - .ok_or_else(|| DeleteError::Deleted(channel.id().clone()))?; + .ok_or_else(|| DeleteError::Deleted(conversation.id().clone()))?; let mut events = Vec::new(); - let messages = tx.messages().live(&channel).await?; + let messages = tx.messages().live(&conversation).await?; let has_messages = messages .iter() .map(message::History::as_snapshot) .any(|message| message.is_some()); if has_messages { - return Err(DeleteError::NotEmpty(channel.id().clone())); + return Err(DeleteError::NotEmpty(conversation.id().clone())); } let deleted = tx.sequence().next(deleted_at).await?; - let channel = tx.channels().delete(&channel, &deleted).await?; + let conversation = tx.conversations().delete(&conversation, &deleted).await?; events.extend( - channel + conversation .events() .filter(Sequence::start_from(deleted.sequence)) .map(Event::from), @@ -98,19 +110,19 @@ impl<'a> Channels<'a> { } pub async fn expire(&self, relative_to: &DateTime) -> Result<(), ExpireError> { - // Somewhat arbitrarily, expire after 7 days. Active channels will not be + // Somewhat arbitrarily, expire after 7 days. Active conversation will not be // expired until their messages expire. let expire_at = relative_to.to_owned() - TimeDelta::days(7); let mut tx = self.db.begin().await?; - let expired = tx.channels().expired(&expire_at).await?; + let expired = tx.conversations().expired(&expire_at).await?; let mut events = Vec::with_capacity(expired.len()); - for channel in expired { + for conversation in expired { let deleted = tx.sequence().next(relative_to).await?; - let channel = tx.channels().delete(&channel, &deleted).await?; + let conversation = tx.conversations().delete(&conversation, &deleted).await?; events.push( - channel + conversation .events() .filter(Sequence::start_from(deleted.sequence)), ); @@ -134,7 +146,7 @@ impl<'a> Channels<'a> { let purge_at = relative_to.to_owned() - TimeDelta::hours(6); let mut tx = self.db.begin().await?; - tx.channels().purge(&purge_at).await?; + tx.conversations().purge(&purge_at).await?; tx.commit().await?; Ok(()) @@ -143,9 +155,9 @@ impl<'a> Channels<'a> { #[derive(Debug, thiserror::Error)] pub enum CreateError { - #[error("channel named {0} already exists")] + #[error("conversation named {0} already exists")] DuplicateName(Name), - #[error("invalid channel name: {0}")] + #[error("invalid conversation name: {0}")] InvalidName(Name), #[error(transparent)] Database(#[from] sqlx::Error), @@ -164,9 +176,9 @@ impl From<LoadError> for CreateError { #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("channel {0} not found")] + #[error("conversation {0} not found")] NotFound(Id), - #[error("channel {0} deleted")] + #[error("conversation {0} deleted")] Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), @@ -185,11 +197,11 @@ impl From<LoadError> for Error { #[derive(Debug, thiserror::Error)] pub enum DeleteError { - #[error("channel {0} not found")] + #[error("conversation {0} not found")] NotFound(Id), - #[error("channel {0} deleted")] + #[error("conversation {0} deleted")] Deleted(Id), - #[error("channel {0} not empty")] + #[error("conversation {0} not empty")] NotEmpty(Id), #[error(transparent)] Database(#[from] sqlx::Error), diff --git a/src/channel/event.rs b/src/conversation/event.rs index a5739f9..f5e8a81 100644 --- a/src/channel/event.rs +++ b/src/conversation/event.rs @@ -1,6 +1,6 @@ -use super::Channel; +use super::Conversation; use crate::{ - channel, + conversation, event::{Instant, Sequenced}, }; @@ -14,7 +14,7 @@ pub enum Event { impl Sequenced for Event { fn instant(&self) -> Instant { match self { - Self::Created(event) => event.channel.created, + Self::Created(event) => event.conversation.created, Self::Deleted(event) => event.instant, } } @@ -23,7 +23,7 @@ impl Sequenced for Event { #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Created { #[serde(flatten)] - pub channel: Channel, + pub conversation: Conversation, } impl From<Created> for Event { @@ -36,7 +36,7 @@ impl From<Created> for Event { pub struct Deleted { #[serde(flatten)] pub instant: Instant, - pub id: channel::Id, + pub id: conversation::Id, } impl From<Deleted> for Event { diff --git a/src/channel/handlers/create/mod.rs b/src/conversation/handlers/create/mod.rs index 2c860fc..18eca1f 100644 --- a/src/channel/handlers/create/mod.rs +++ b/src/conversation/handlers/create/mod.rs @@ -6,8 +6,8 @@ use axum::{ use crate::{ app::App, - channel::{Channel, app}, clock::RequestedAt, + conversation::{Conversation, app}, error::Internal, name::Name, token::extract::Identity, @@ -22,13 +22,13 @@ pub async fn handler( RequestedAt(created_at): RequestedAt, Json(request): Json<Request>, ) -> Result<Response, Error> { - let channel = app - .channels() + let conversation = app + .conversations() .create(&request.name, &created_at) .await .map_err(Error)?; - Ok(Response(channel)) + Ok(Response(conversation)) } #[derive(serde::Deserialize)] @@ -37,12 +37,12 @@ pub struct Request { } #[derive(Debug)] -pub struct Response(pub Channel); +pub struct Response(pub Conversation); impl IntoResponse for Response { fn into_response(self) -> response::Response { - let Self(channel) = self; - (StatusCode::ACCEPTED, Json(channel)).into_response() + let Self(conversation) = self; + (StatusCode::ACCEPTED, Json(conversation)).into_response() } } diff --git a/src/channel/handlers/create/test.rs b/src/conversation/handlers/create/test.rs index 31bb778..bc05b00 100644 --- a/src/channel/handlers/create/test.rs +++ b/src/conversation/handlers/create/test.rs @@ -5,13 +5,13 @@ use futures::stream::StreamExt as _; use itertools::Itertools; use crate::{ - channel::app, + conversation::app, name::Name, test::fixtures::{self, future::Expect as _}, }; #[tokio::test] -async fn new_channel() { +async fn new_conversation() { // Set up the environment let app = fixtures::scratch_app().await; @@ -20,12 +20,12 @@ async fn new_channel() { // Call the endpoint - let name = fixtures::channel::propose(); + let name = fixtures::conversation::propose(); let request = super::Request { name: name.clone() }; let super::Response(response) = super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect("creating a channel in an empty app succeeds"); + .expect("creating a conversation in an empty app succeeds"); // Verify the structure of the response @@ -37,31 +37,31 @@ async fn new_channel() { let created = snapshot .events .into_iter() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::created) .exactly_one() - .expect("only one channel has been created"); - assert_eq!(response, created.channel); + .expect("only one conversation has been created"); + assert_eq!(response, created.conversation); - let channel = app - .channels() + let conversation = app + .conversations() .get(&response.id) .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); + .expect("the newly-created conversation exists"); + assert_eq!(response, conversation); let mut events = app .events() .subscribe(resume_point) .await .expect("subscribing never fails") - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::created) - .filter(|event| future::ready(event.channel == response)); + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::created) + .filter(|event| future::ready(event.conversation == response)); let event = events.next().expect_some("creation event published").await; - assert_eq!(event.channel, response); + assert_eq!(event.conversation, response); } #[tokio::test] @@ -70,23 +70,23 @@ async fn duplicate_name() { let app = fixtures::scratch_app().await; let creator = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; // Call the endpoint let request = super::Request { - name: channel.name.clone(), + name: conversation.name.clone(), }; let super::Error(error) = super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect_err("duplicate channel name should fail the request"); + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response assert!(matches!( error, - app::CreateError::DuplicateName(name) if channel.name == name + app::CreateError::DuplicateName(name) if conversation.name == name )); } @@ -98,10 +98,10 @@ async fn conflicting_canonical_name() { let creator = fixtures::identity::create(&app, &fixtures::now()).await; let existing_name = Name::from("rijksmuseum"); - app.channels() + app.conversations() .create(&existing_name, &fixtures::now()) .await - .expect("creating a channel in an empty environment succeeds"); + .expect("creating a conversation in an empty environment succeeds"); let conflicting_name = Name::from("r\u{0133}ksmuseum"); @@ -113,7 +113,7 @@ async fn conflicting_canonical_name() { let super::Error(error) = super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect_err("duplicate channel name should fail the request"); + .expect_err("duplicate conversation name should fail the request"); // Verify the structure of the response @@ -132,16 +132,16 @@ async fn invalid_name() { // Call the endpoint - let name = fixtures::channel::propose_invalid_name(); + let name = fixtures::conversation::propose_invalid_name(); let request = super::Request { name: name.clone() }; - let super::Error(error) = crate::channel::handlers::create::handler( + let super::Error(error) = crate::conversation::handlers::create::handler( State(app.clone()), creator, fixtures::now(), Json(request), ) .await - .expect_err("invalid channel name should fail the request"); + .expect_err("invalid conversation name should fail the request"); // Verify the structure of the response @@ -157,7 +157,7 @@ async fn name_reusable_after_delete() { let app = fixtures::scratch_app().await; let creator = fixtures::identity::create(&app, &fixtures::now()).await; - let name = fixtures::channel::propose(); + let name = fixtures::conversation::propose(); // Call the endpoint (first time) @@ -169,14 +169,14 @@ async fn name_reusable_after_delete() { Json(request), ) .await - .expect("new channel in an empty app"); + .expect("new conversation in an empty app"); - // Delete the channel + // Delete the conversation - app.channels() + app.conversations() .delete(&response.id, &fixtures::now()) .await - .expect("deleting a newly-created channel succeeds"); + .expect("deleting a newly-created conversation succeeds"); // Call the endpoint (second time) @@ -184,7 +184,7 @@ async fn name_reusable_after_delete() { let super::Response(response) = super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect("creation succeeds after original channel deleted"); + .expect("creation succeeds after original conversation deleted"); // Verify the structure of the response @@ -192,12 +192,12 @@ async fn name_reusable_after_delete() { // Verify the semantics - let channel = app - .channels() + let conversation = app + .conversations() .get(&response.id) .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); + .expect("the newly-created conversation exists"); + assert_eq!(response, conversation); } #[tokio::test] @@ -206,7 +206,7 @@ async fn name_reusable_after_expiry() { let app = fixtures::scratch_app().await; let creator = fixtures::identity::create(&app, &fixtures::ancient()).await; - let name = fixtures::channel::propose(); + let name = fixtures::conversation::propose(); // Call the endpoint (first time) @@ -218,11 +218,11 @@ async fn name_reusable_after_expiry() { Json(request), ) .await - .expect("new channel in an empty app"); + .expect("new conversation in an empty app"); - // Delete the channel + // Expire the conversation - app.channels() + app.conversations() .expire(&fixtures::now()) .await .expect("expiry always succeeds"); @@ -233,7 +233,7 @@ async fn name_reusable_after_expiry() { let super::Response(response) = super::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect("creation succeeds after original channel expired"); + .expect("creation succeeds after original conversation expired"); // Verify the structure of the response @@ -241,10 +241,10 @@ async fn name_reusable_after_expiry() { // Verify the semantics - let channel = app - .channels() + let conversation = app + .conversations() .get(&response.id) .await - .expect("the newly-created channel exists"); - assert_eq!(response, channel); + .expect("the newly-created conversation exists"); + assert_eq!(response, conversation); } diff --git a/src/channel/handlers/delete/mod.rs b/src/conversation/handlers/delete/mod.rs index b986bec..272165a 100644 --- a/src/channel/handlers/delete/mod.rs +++ b/src/conversation/handlers/delete/mod.rs @@ -6,8 +6,8 @@ use axum::{ use crate::{ app::App, - channel::{self, app, handlers::PathInfo}, clock::RequestedAt, + conversation::{self, app, handlers::PathInfo}, error::{Internal, NotFound}, token::extract::Identity, }; @@ -17,18 +17,20 @@ mod test; pub async fn handler( State(app): State<App>, - Path(channel): Path<PathInfo>, + Path(conversation): Path<PathInfo>, RequestedAt(deleted_at): RequestedAt, _: Identity, ) -> Result<Response, Error> { - app.channels().delete(&channel, &deleted_at).await?; + app.conversations() + .delete(&conversation, &deleted_at) + .await?; - Ok(Response { id: channel }) + Ok(Response { id: conversation }) } #[derive(Debug, serde::Serialize)] pub struct Response { - pub id: channel::Id, + pub id: conversation::Id, } impl IntoResponse for Response { diff --git a/src/channel/handlers/delete/test.rs b/src/conversation/handlers/delete/test.rs index 99c19db..2718d3b 100644 --- a/src/channel/handlers/delete/test.rs +++ b/src/conversation/handlers/delete/test.rs @@ -1,30 +1,30 @@ use axum::extract::{Path, State}; use itertools::Itertools; -use crate::{channel::app, test::fixtures}; +use crate::{conversation::app, test::fixtures}; #[tokio::test] -pub async fn valid_channel() { +pub async fn valid_conversation() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let response = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), fixtures::now(), deleter, ) .await - .expect("deleting a valid channel succeeds"); + .expect("deleting a valid conversation succeeds"); // Verify the response - assert_eq!(channel.id, response.id); + assert_eq!(conversation.id, response.id); // Verify the semantics @@ -32,17 +32,17 @@ pub async fn valid_channel() { let created = snapshot .events .into_iter() - .filter_map(fixtures::event::channel) - .filter_map(fixtures::event::channel::created) + .filter_map(fixtures::event::conversation) + .filter_map(fixtures::event::conversation::created) .exactly_one() - .expect("only one channel has been created"); - // We don't expect `channel` to match the event exactly, as the name will have been - // tombstoned and the channel given a `deleted_at` date. - assert_eq!(channel.id, created.channel.id); + .expect("only one conversation has been created"); + // We don't expect `conversation` to match the event exactly, as the name will have + // been tombstoned and the conversation given a `deleted_at` date. + assert_eq!(conversation.id, created.conversation.id); } #[tokio::test] -pub async fn invalid_channel_id() { +pub async fn invalid_conversation_id() { // Set up the environment let app = fixtures::scratch_app().await; @@ -50,135 +50,135 @@ pub async fn invalid_channel_id() { // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::fictitious(); + let conversation = fixtures::conversation::fictitious(); let super::Error(error) = super::handler( State(app.clone()), - Path(channel.clone()), + Path(conversation.clone()), fixtures::now(), deleter, ) .await - .expect_err("deleting a nonexistent channel fails"); + .expect_err("deleting a nonexistent conversation fails"); // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel)); + assert!(matches!(error, app::DeleteError::NotFound(id) if id == conversation)); } #[tokio::test] -pub async fn channel_deleted() { +pub async fn conversation_deleted() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; - app.channels() - .delete(&channel.id, &fixtures::now()) + app.conversations() + .delete(&conversation.id, &fixtures::now()) .await - .expect("deleting a recently-sent channel succeeds"); + .expect("deleting a recently-created conversation succeeds"); // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), fixtures::now(), deleter, ) .await - .expect_err("deleting a deleted channel fails"); + .expect_err("deleting a deleted conversation fails"); // Verify the response - assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); + assert!(matches!(error, app::DeleteError::Deleted(id) if id == conversation.id)); } #[tokio::test] -pub async fn channel_expired() { +pub async fn conversation_expired() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; - app.channels() + app.conversations() .expire(&fixtures::now()) .await - .expect("expiring channels always succeeds"); + .expect("expiring conversations always succeeds"); // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), fixtures::now(), deleter, ) .await - .expect_err("deleting an expired channel fails"); + .expect_err("deleting an expired conversation fails"); // Verify the response - assert!(matches!(error, app::DeleteError::Deleted(id) if id == channel.id)); + assert!(matches!(error, app::DeleteError::Deleted(id) if id == conversation.id)); } #[tokio::test] -pub async fn channel_purged() { +pub async fn conversation_purged() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; - app.channels() + app.conversations() .expire(&fixtures::old()) .await - .expect("expiring channels always succeeds"); + .expect("expiring conversations always succeeds"); - app.channels() + app.conversations() .purge(&fixtures::now()) .await - .expect("purging channels always succeeds"); + .expect("purging conversations always succeeds"); // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), fixtures::now(), deleter, ) .await - .expect_err("deleting a purged channel fails"); + .expect_err("deleting a purged conversation fails"); // Verify the response - assert!(matches!(error, app::DeleteError::NotFound(id) if id == channel.id)); + assert!(matches!(error, app::DeleteError::NotFound(id) if id == conversation.id)); } #[tokio::test] -pub async fn channel_not_empty() { +pub async fn conversation_not_empty() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; // Send the request let deleter = fixtures::identity::create(&app, &fixtures::now()).await; let super::Error(error) = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), fixtures::now(), deleter, ) .await - .expect_err("deleting a channel with messages fails"); + .expect_err("deleting a conversation with messages fails"); // Verify the response - assert!(matches!(error, app::DeleteError::NotEmpty(id) if id == channel.id)); + assert!(matches!(error, app::DeleteError::NotEmpty(id) if id == conversation.id)); } diff --git a/src/channel/handlers/mod.rs b/src/conversation/handlers/mod.rs index f2ffd0d..2fe727c 100644 --- a/src/channel/handlers/mod.rs +++ b/src/conversation/handlers/mod.rs @@ -6,4 +6,4 @@ pub use create::handler as create; pub use delete::handler as delete; pub use send::handler as send; -type PathInfo = crate::channel::Id; +type PathInfo = crate::conversation::Id; diff --git a/src/channel/handlers/send/mod.rs b/src/conversation/handlers/send/mod.rs index bde39e5..9ec020a 100644 --- a/src/channel/handlers/send/mod.rs +++ b/src/conversation/handlers/send/mod.rs @@ -4,7 +4,7 @@ use axum::{ response::{self, IntoResponse}, }; -use crate::channel::handlers::PathInfo; +use crate::conversation::handlers::PathInfo; use crate::{ app::App, clock::RequestedAt, @@ -18,14 +18,14 @@ mod test; pub async fn handler( State(app): State<App>, - Path(channel): Path<PathInfo>, + Path(conversation): Path<PathInfo>, RequestedAt(sent_at): RequestedAt, identity: Identity, Json(request): Json<Request>, ) -> Result<Response, Error> { let message = app .messages() - .send(&channel, &identity.user, &sent_at, &request.body) + .send(&conversation, &identity.user, &sent_at, &request.body) .await?; Ok(Response(message)) @@ -54,7 +54,7 @@ impl IntoResponse for Error { fn into_response(self) -> response::Response { let Self(error) = self; match error { - SendError::ChannelNotFound(_) | SendError::ChannelDeleted(_) => { + SendError::ConversationNotFound(_) | SendError::ConversationDeleted(_) => { NotFound(error).into_response() } SendError::Name(_) | SendError::Database(_) => Internal::from(error).into_response(), diff --git a/src/channel/handlers/send/test.rs b/src/conversation/handlers/send/test.rs index 70d45eb..bd32510 100644 --- a/src/channel/handlers/send/test.rs +++ b/src/conversation/handlers/send/test.rs @@ -2,7 +2,7 @@ use axum::extract::{Json, Path, State}; use futures::stream::{self, StreamExt as _}; use crate::{ - channel, + conversation, event::Sequenced, message::app::SendError, test::fixtures::{self, future::Expect as _}, @@ -14,7 +14,7 @@ async fn messages_in_order() { let app = fixtures::scratch_app().await; let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Call the endpoint (twice) @@ -29,13 +29,13 @@ async fn messages_in_order() { let _ = super::handler( State(app.clone()), - Path(channel.id.clone()), + Path(conversation.id.clone()), sent_at.clone(), sender.clone(), Json(request), ) .await - .expect("sending to a valid channel succeeds"); + .expect("sending to a valid conversation succeeds"); } // Verify the semantics @@ -44,7 +44,7 @@ async fn messages_in_order() { .events() .subscribe(resume_point) .await - .expect("subscribing to a valid channel succeeds") + .expect("subscribing always succeeds") .filter_map(fixtures::event::stream::message) .filter_map(fixtures::event::stream::message::sent) .zip(stream::iter(requests)); @@ -61,7 +61,7 @@ async fn messages_in_order() { } #[tokio::test] -async fn nonexistent_channel() { +async fn nonexistent_conversation() { // Set up the environment let app = fixtures::scratch_app().await; @@ -70,40 +70,40 @@ async fn nonexistent_channel() { // Call the endpoint let sent_at = fixtures::now(); - let channel = channel::Id::generate(); + let conversation = conversation::Id::generate(); let request = super::Request { body: fixtures::message::propose(), }; let super::Error(error) = super::handler( State(app), - Path(channel.clone()), + Path(conversation.clone()), sent_at, sender, Json(request), ) .await - .expect_err("sending to a nonexistent channel fails"); + .expect_err("sending to a nonexistent conversation fails"); // Verify the structure of the response assert!(matches!( error, - SendError::ChannelNotFound(error_channel) if channel == error_channel + SendError::ConversationNotFound(error_conversation) if conversation == error_conversation )); } #[tokio::test] -async fn deleted_channel() { +async fn deleted_conversation() { // Set up the environment let app = fixtures::scratch_app().await; let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; - app.channels() - .delete(&channel.id, &fixtures::now()) + app.conversations() + .delete(&conversation.id, &fixtures::now()) .await - .expect("deleting a new channel succeeds"); + .expect("deleting a new conversation succeeds"); // Call the endpoint @@ -113,18 +113,18 @@ async fn deleted_channel() { }; let super::Error(error) = super::handler( State(app), - Path(channel.id.clone()), + Path(conversation.id.clone()), sent_at, sender, Json(request), ) .await - .expect_err("sending to a deleted channel fails"); + .expect_err("sending to a deleted conversation fails"); // Verify the structure of the response assert!(matches!( error, - SendError::ChannelDeleted(error_channel) if channel.id == error_channel + SendError::ConversationDeleted(error_conversation) if conversation.id == error_conversation )); } diff --git a/src/channel/history.rs b/src/conversation/history.rs index 85da5a5..601614c 100644 --- a/src/channel/history.rs +++ b/src/conversation/history.rs @@ -1,33 +1,33 @@ use itertools::Itertools as _; use super::{ - Channel, Id, + Conversation, Id, event::{Created, Deleted, Event}, }; use crate::event::{Instant, Sequence}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct History { - pub channel: Channel, + pub conversation: Conversation, pub deleted: Option<Instant>, } // State interface impl History { pub fn id(&self) -> &Id { - &self.channel.id + &self.conversation.id } - // Snapshot of this channel as it was when created. (Note to the future: it's - // okay if this returns a redacted or modified version of the channel. If we - // implement renames by redacting the original name, then this should return the - // renamed channel, not the original, even if that's not how it was "as - // created.") - pub fn as_created(&self) -> Channel { - self.channel.clone() + // Snapshot of this conversation as it was when created. (Note to the future: + // it's okay if this returns a redacted or modified version of the conversation. + // If we implement renames by redacting the original name, then this should + // return the renamed conversation, not the original, even if that's not how + // it was "as created.") + pub fn as_created(&self) -> Conversation { + self.conversation.clone() } - pub fn as_of<S>(&self, sequence: S) -> Option<Channel> + pub fn as_of<S>(&self, sequence: S) -> Option<Conversation> where S: Into<Sequence>, { @@ -36,8 +36,8 @@ impl History { .collect() } - // Snapshot of this channel as of all events recorded in this history. - pub fn as_snapshot(&self) -> Option<Channel> { + // Snapshot of this conversation as of all events recorded in this history. + pub fn as_snapshot(&self) -> Option<Conversation> { self.events().collect() } } @@ -52,7 +52,7 @@ impl History { fn created(&self) -> Event { Created { - channel: self.channel.clone(), + conversation: self.conversation.clone(), } .into() } @@ -61,7 +61,7 @@ impl History { self.deleted.map(|instant| { Deleted { instant, - id: self.channel.id.clone(), + id: self.conversation.id.clone(), } .into() }) diff --git a/src/channel/id.rs b/src/conversation/id.rs index 22a2700..5f37a59 100644 --- a/src/channel/id.rs +++ b/src/conversation/id.rs @@ -2,7 +2,7 @@ use std::fmt; use crate::id::Id as BaseId; -// Stable identifier for a [Channel]. Prefixed with `C`. +// Stable identifier for a [Conversation]. Prefixed with `C`. #[derive( Clone, Debug, diff --git a/src/channel/mod.rs b/src/conversation/mod.rs index bbaf33e..3dfa187 100644 --- a/src/channel/mod.rs +++ b/src/conversation/mod.rs @@ -7,4 +7,4 @@ pub mod repo; mod snapshot; mod validate; -pub use self::{event::Event, history::History, id::Id, snapshot::Channel}; +pub use self::{event::Event, history::History, id::Id, snapshot::Conversation}; diff --git a/src/channel/repo.rs b/src/conversation/repo.rs index 812a259..82b5f01 100644 --- a/src/channel/repo.rs +++ b/src/conversation/repo.rs @@ -2,26 +2,26 @@ use futures::stream::{StreamExt as _, TryStreamExt as _}; use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use crate::{ - channel::{Channel, History, Id}, clock::DateTime, + conversation::{Conversation, History, Id}, db::NotFound, event::{Instant, Sequence}, name::{self, Name}, }; pub trait Provider { - fn channels(&mut self) -> Channels; + fn conversations(&mut self) -> Conversations; } impl Provider for Transaction<'_, Sqlite> { - fn channels(&mut self) -> Channels { - Channels(self) + fn conversations(&mut self) -> Conversations { + Conversations(self) } } -pub struct Channels<'t>(&'t mut SqliteConnection); +pub struct Conversations<'t>(&'t mut SqliteConnection); -impl Channels<'_> { +impl Conversations<'_> { pub async fn create(&mut self, name: &Name, created: &Instant) -> Result<History, sqlx::Error> { let id = Id::generate(); let name = name.clone(); @@ -31,8 +31,7 @@ impl Channels<'_> { sqlx::query!( r#" - insert - into channel (id, created_at, created_sequence, last_sequence) + insert into conversation (id, created_at, created_sequence, last_sequence) values ($1, $2, $3, $4) "#, id, @@ -45,7 +44,7 @@ impl Channels<'_> { sqlx::query!( r#" - insert into channel_name (id, display_name, canonical_name) + insert into conversation_name (id, display_name, canonical_name) values ($1, $2, $3) "#, id, @@ -55,8 +54,8 @@ impl Channels<'_> { .execute(&mut *self.0) .await?; - let channel = History { - channel: Channel { + let conversation = History { + conversation: Conversation { created, id, name: name.clone(), @@ -65,32 +64,32 @@ impl Channels<'_> { deleted: None, }; - Ok(channel) + Ok(conversation) } - pub async fn by_id(&mut self, channel: &Id) -> Result<History, LoadError> { - let channel = sqlx::query!( + pub async fn by_id(&mut self, conversation: &Id) -> Result<History, LoadError> { + let conversation = sqlx::query!( r#" select id as "id: Id", 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", + conversation.created_at as "created_at: DateTime", + conversation.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 + from conversation + left join conversation_name as name using (id) - left join channel_deleted as deleted + left join conversation_deleted as deleted using (id) where id = $1 "#, - channel, + conversation, ) .map(|row| { Ok::<_, name::Error>(History { - channel: Channel { + conversation: Conversation { created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), @@ -102,33 +101,33 @@ impl Channels<'_> { .fetch_one(&mut *self.0) .await??; - Ok(channel) + Ok(conversation) } pub async fn all(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { - let channels = sqlx::query!( + let conversations = sqlx::query!( r#" select id as "id: Id", 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", + conversation.created_at as "created_at: DateTime", + conversation.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 + from conversation + left join conversation_name as name using (id) - left join channel_deleted as deleted + left join conversation_deleted as deleted using (id) - where channel.created_sequence <= $1 + where conversation.created_sequence <= $1 order by name.canonical_name "#, resume_at, ) .map(|row| { Ok::<_, name::Error>(History { - channel: Channel { + conversation: Conversation { created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), @@ -142,32 +141,32 @@ impl Channels<'_> { .try_collect() .await?; - Ok(channels) + Ok(conversations) } pub async fn replay(&mut self, resume_at: Sequence) -> Result<Vec<History>, LoadError> { - let channels = sqlx::query!( + let conversations = sqlx::query!( r#" select id as "id: Id", 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", + conversation.created_at as "created_at: DateTime", + conversation.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 + from conversation + left join conversation_name as name using (id) - left join channel_deleted as deleted + left join conversation_deleted as deleted using (id) - where channel.last_sequence > $1 + where conversation.last_sequence > $1 "#, resume_at, ) .map(|row| { Ok::<_, name::Error>(History { - channel: Channel { + conversation: Conversation { created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), @@ -181,18 +180,18 @@ impl Channels<'_> { .try_collect() .await?; - Ok(channels) + Ok(conversations) } pub async fn delete( &mut self, - channel: &History, + conversation: &History, deleted: &Instant, ) -> Result<History, LoadError> { - let id = channel.id(); + let id = conversation.id(); sqlx::query!( r#" - update channel + update conversation set last_sequence = max(last_sequence, $1) where id = $2 returning id as "id: Id" @@ -205,7 +204,7 @@ impl Channels<'_> { sqlx::query!( r#" - insert into channel_deleted (id, deleted_at, deleted_sequence) + insert into conversation_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) "#, id, @@ -215,17 +214,13 @@ impl Channels<'_> { .execute(&mut *self.0) .await?; - // Small social responsibility hack here: when a channel is deleted, its name is - // retconned to have been the empty string. Someone reading the event stream - // afterwards, or looking at channels via the API, cannot retrieve the - // "deleted" channel's information by ignoring the deletion event. - // - // 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. + // Small social responsibility hack here: when a conversation is deleted, its + // name is retconned to have been the empty string. Someone reading the event + // stream afterwards, or looking at conversations via the API, cannot retrieve + // the "deleted" conversation's information by ignoring the deletion event. sqlx::query!( r#" - delete from channel_name + delete from conversation_name where id = $1 "#, id, @@ -233,20 +228,20 @@ impl Channels<'_> { .execute(&mut *self.0) .await?; - let channel = self.by_id(id).await?; + let conversation = self.by_id(id).await?; - Ok(channel) + Ok(conversation) } pub async fn purge(&mut self, purge_at: &DateTime) -> Result<(), sqlx::Error> { - let channels = sqlx::query_scalar!( + let conversations = sqlx::query_scalar!( r#" with has_messages as ( - select channel + select conversation from message - group by channel + group by conversation ) - delete from channel_deleted + delete from conversation_deleted where deleted_at < $1 and id not in has_messages returning id as "id: Id" @@ -256,14 +251,14 @@ impl Channels<'_> { .fetch_all(&mut *self.0) .await?; - for channel in channels { + for conversation in conversations { // Wanted: a way to batch these up into one query. sqlx::query!( r#" - delete from channel + delete from conversation where id = $1 "#, - channel, + conversation, ) .execute(&mut *self.0) .await?; @@ -273,24 +268,24 @@ impl Channels<'_> { } pub async fn expired(&mut self, expired_at: &DateTime) -> Result<Vec<History>, LoadError> { - let channels = sqlx::query!( + let conversations = sqlx::query!( r#" select - channel.id as "id: Id", + conversation.id as "id: Id", 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", + conversation.created_at as "created_at: DateTime", + conversation.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 + from conversation + left join conversation_name as name using (id) - left join channel_deleted as deleted + left join conversation_deleted as deleted using (id) left join message - on channel.id = message.channel - where channel.created_at < $1 + on conversation.id = message.conversation + where conversation.created_at < $1 and message.id is null and deleted.id is null "#, @@ -298,7 +293,7 @@ impl Channels<'_> { ) .map(|row| { Ok::<_, name::Error>(History { - channel: Channel { + conversation: Conversation { created: Instant::new(row.created_at, row.created_sequence), id: row.id, name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), @@ -312,7 +307,7 @@ impl Channels<'_> { .try_collect() .await?; - Ok(channels) + Ok(conversations) } } diff --git a/src/channel/snapshot.rs b/src/conversation/snapshot.rs index 96801b8..da9eaae 100644 --- a/src/channel/snapshot.rs +++ b/src/conversation/snapshot.rs @@ -5,7 +5,7 @@ use super::{ use crate::{clock::DateTime, event::Instant, name::Name}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] -pub struct Channel { +pub struct Conversation { #[serde(flatten)] pub created: Instant, pub id: Id, @@ -14,30 +14,30 @@ pub struct Channel { pub deleted_at: Option<DateTime>, } -impl Channel { +impl Conversation { fn apply(state: Option<Self>, event: Event) -> Option<Self> { match (state, event) { (None, Event::Created(event)) => Some(event.into()), - (Some(channel), Event::Deleted(event)) if channel.id == event.id => None, - (state, event) => panic!("invalid channel event {event:#?} for state {state:#?}"), + (Some(conversation), Event::Deleted(event)) if conversation.id == event.id => None, + (state, event) => panic!("invalid conversation event {event:#?} for state {state:#?}"), } } } -impl FromIterator<Event> for Option<Channel> { +impl FromIterator<Event> for Option<Conversation> { fn from_iter<I: IntoIterator<Item = Event>>(events: I) -> Self { - events.into_iter().fold(None, Channel::apply) + events.into_iter().fold(None, Conversation::apply) } } -impl From<&Created> for Channel { +impl From<&Created> for Conversation { fn from(event: &Created) -> Self { - event.channel.clone() + event.conversation.clone() } } -impl From<Created> for Channel { +impl From<Created> for Conversation { fn from(event: Created) -> Self { - event.channel + event.conversation } } diff --git a/src/channel/validate.rs b/src/conversation/validate.rs index 7894e0c..7894e0c 100644 --- a/src/channel/validate.rs +++ b/src/conversation/validate.rs diff --git a/src/event/app.rs b/src/event/app.rs index 45a9099..7359bfb 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -7,7 +7,7 @@ use sqlx::sqlite::SqlitePool; use super::{Event, Sequence, Sequenced, broadcaster::Broadcaster}; use crate::{ - channel::{self, repo::Provider as _}, + conversation::{self, repo::Provider as _}, message::{self, repo::Provider as _}, name, user::{self, repo::Provider as _}, @@ -41,10 +41,10 @@ impl<'a> Events<'a> { .filter(Sequence::after(resume_at)) .map(Event::from); - let channels = tx.channels().replay(resume_at).await?; - let channel_events = channels + let conversations = tx.conversations().replay(resume_at).await?; + let conversation_events = conversations .iter() - .map(channel::History::events) + .map(conversation::History::events) .kmerge_by(Sequence::merge) .filter(Sequence::after(resume_at)) .map(Event::from); @@ -58,7 +58,7 @@ impl<'a> Events<'a> { .map(Event::from); let replay_events = user_events - .merge_by(channel_events, Sequence::merge) + .merge_by(conversation_events, Sequence::merge) .merge_by(message_events, Sequence::merge) .collect::<Vec<_>>(); let resume_live_at = replay_events.last().map_or(resume_at, Sequenced::sequence); @@ -98,9 +98,9 @@ impl From<user::repo::LoadError> for Error { } } -impl From<channel::repo::LoadError> for Error { - fn from(error: channel::repo::LoadError) -> Self { - use channel::repo::LoadError; +impl From<conversation::repo::LoadError> for Error { + fn from(error: conversation::repo::LoadError) -> Self { + use conversation::repo::LoadError; match error { LoadError::Database(error) => error.into(), LoadError::Name(error) => error.into(), diff --git a/src/event/handlers/stream/test/channel.rs b/src/event/handlers/stream/test/conversation.rs index 2b87ce2..5e08075 100644 --- a/src/event/handlers/stream/test/channel.rs +++ b/src/event/handlers/stream/test/conversation.rs @@ -23,23 +23,23 @@ async fn creating() { .await .expect("subscribe never fails"); - // Create a channel + // Create a conversation - let name = fixtures::channel::propose(); - let channel = app - .channels() + let name = fixtures::conversation::propose(); + let conversation = app + .conversations() .create(&name, &fixtures::now()) .await - .expect("creating a channel succeeds"); + .expect("creating a conversation succeeds"); - // Verify channel created event + // Verify conversation created event events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::created) - .filter(|event| future::ready(event.channel == channel)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::created) + .filter(|event| future::ready(event.conversation == conversation)) .next() - .expect_some("channel created event is delivered") + .expect_some("conversation created event is delivered") .await; } @@ -50,14 +50,14 @@ async fn previously_created() { let app = fixtures::scratch_app().await; let resume_point = fixtures::boot::resume_point(&app).await; - // Create a channel + // Create a conversation - let name = fixtures::channel::propose(); - let channel = app - .channels() + let name = fixtures::conversation::propose(); + let conversation = app + .conversations() .create(&name, &fixtures::now()) .await - .expect("creating a channel succeeds"); + .expect("creating a conversation succeeds"); // Subscribe @@ -71,14 +71,14 @@ async fn previously_created() { .await .expect("subscribe never fails"); - // Verify channel created event + // Verify conversation created event let _ = events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::created) - .filter(|event| future::ready(event.channel == channel)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::created) + .filter(|event| future::ready(event.conversation == conversation)) .next() - .expect_some("channel created event is delivered") + .expect_some("conversation created event is delivered") .await; } @@ -87,7 +87,7 @@ async fn expiring() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Subscribe @@ -102,20 +102,20 @@ async fn expiring() { .await .expect("subscribe never fails"); - // Expire channels + // Expire conversations - app.channels() + app.conversations() .expire(&fixtures::now()) .await - .expect("expiring channels always succeeds"); + .expect("expiring conversations always succeeds"); // Check for expiry event let _ = events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::deleted) + .filter(|event| future::ready(event.id == conversation.id)) .next() - .expect_some("a deleted channel event will be delivered") + .expect_some("a deleted conversation event will be delivered") .await; } @@ -124,15 +124,15 @@ async fn previously_expired() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; - // Expire channels + // Expire conversations - app.channels() + app.conversations() .expire(&fixtures::now()) .await - .expect("expiring channels always succeeds"); + .expect("expiring conversation always succeeds"); // Subscribe @@ -148,11 +148,11 @@ async fn previously_expired() { // Check for expiry event let _ = events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::deleted) + .filter(|event| future::ready(event.id == conversation.id)) .next() - .expect_some("a deleted channel event will be delivered") + .expect_some("a deleted conversation event will be delivered") .await; } @@ -161,7 +161,7 @@ async fn deleting() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Subscribe @@ -176,20 +176,20 @@ async fn deleting() { .await .expect("subscribe never fails"); - // Delete the channel + // Delete the conversation - app.channels() - .delete(&channel.id, &fixtures::now()) + app.conversations() + .delete(&conversation.id, &fixtures::now()) .await - .expect("deleting a valid channel succeeds"); + .expect("deleting a valid conversation succeeds"); // Check for delete event let _ = events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::deleted) + .filter(|event| future::ready(event.id == conversation.id)) .next() - .expect_some("a deleted channel event will be delivered") + .expect_some("a deleted conversation event will be delivered") .await; } @@ -198,15 +198,15 @@ async fn previously_deleted() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; - // Delete the channel + // Delete the conversation - app.channels() - .delete(&channel.id, &fixtures::now()) + app.conversations() + .delete(&conversation.id, &fixtures::now()) .await - .expect("deleting a valid channel succeeds"); + .expect("deleting a valid conversation succeeds"); // Subscribe @@ -222,11 +222,11 @@ async fn previously_deleted() { // Check for expiry event let _ = events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::deleted) + .filter(|event| future::ready(event.id == conversation.id)) .next() - .expect_some("a deleted channel event will be delivered") + .expect_some("a deleted conversation event will be delivered") .await; } @@ -235,20 +235,20 @@ async fn previously_purged() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; - // Delete and purge the channel + // Delete and purge the conversation - app.channels() - .delete(&channel.id, &fixtures::ancient()) + app.conversations() + .delete(&conversation.id, &fixtures::ancient()) .await - .expect("deleting a valid channel succeeds"); + .expect("deleting a valid conversation succeeds"); - app.channels() + app.conversations() .purge(&fixtures::now()) .await - .expect("purging channels always succeeds"); + .expect("purging conversations always succeeds"); // Subscribe @@ -264,10 +264,10 @@ async fn previously_purged() { // Check for expiry event events - .filter_map(fixtures::event::stream::channel) - .filter_map(fixtures::event::stream::channel::deleted) - .filter(|event| future::ready(event.id == channel.id)) + .filter_map(fixtures::event::stream::conversation) + .filter_map(fixtures::event::stream::conversation::deleted) + .filter(|event| future::ready(event.id == conversation.id)) .next() - .expect_wait("deleted channel events not delivered") + .expect_wait("deleted conversation events not delivered") .await; } diff --git a/src/event/handlers/stream/test/message.rs b/src/event/handlers/stream/test/message.rs index 4369996..3fba317 100644 --- a/src/event/handlers/stream/test/message.rs +++ b/src/event/handlers/stream/test/message.rs @@ -12,7 +12,7 @@ async fn sending() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Call the endpoint @@ -33,7 +33,7 @@ async fn sending() { let message = app .messages() .send( - &channel.id, + &conversation.id, &sender, &fixtures::now(), &fixtures::message::propose(), @@ -57,7 +57,7 @@ async fn previously_sent() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Send a message @@ -66,7 +66,7 @@ async fn previously_sent() { let message = app .messages() .send( - &channel.id, + &conversation.id, &sender, &fixtures::now(), &fixtures::message::propose(), @@ -98,27 +98,30 @@ async fn previously_sent() { } #[tokio::test] -async fn sent_in_multiple_channels() { +async fn sent_in_multiple_conversations() { // Set up the environment let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; - let channels = [ - fixtures::channel::create(&app, &fixtures::now()).await, - fixtures::channel::create(&app, &fixtures::now()).await, + let conversations = [ + fixtures::conversation::create(&app, &fixtures::now()).await, + fixtures::conversation::create(&app, &fixtures::now()).await, ]; - let messages = stream::iter(channels) - .then(|channel| { - let app = app.clone(); - let sender = sender.clone(); - let channel = channel.clone(); - async move { fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await } - }) - .collect::<Vec<_>>() - .await; + let messages = + stream::iter(conversations) + .then(|conversation| { + let app = app.clone(); + let sender = sender.clone(); + let conversation = conversation.clone(); + async move { + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await + } + }) + .collect::<Vec<_>>() + .await; // Call the endpoint @@ -152,14 +155,14 @@ async fn sent_sequentially() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; let messages = vec![ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, ]; // Subscribe @@ -196,9 +199,9 @@ async fn expiring() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Subscribe @@ -235,9 +238,9 @@ async fn previously_expired() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Expire messages @@ -274,9 +277,9 @@ async fn deleting() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Subscribe @@ -313,9 +316,9 @@ async fn previously_deleted() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Delete the message @@ -352,9 +355,9 @@ async fn previously_purged() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Purge the message diff --git a/src/event/handlers/stream/test/mod.rs b/src/event/handlers/stream/test/mod.rs index df43deb..3bc634f 100644 --- a/src/event/handlers/stream/test/mod.rs +++ b/src/event/handlers/stream/test/mod.rs @@ -1,4 +1,4 @@ -mod channel; +mod conversation; mod invite; mod message; mod resume; diff --git a/src/event/handlers/stream/test/resume.rs b/src/event/handlers/stream/test/resume.rs index 835d350..a0da692 100644 --- a/src/event/handlers/stream/test/resume.rs +++ b/src/event/handlers/stream/test/resume.rs @@ -14,15 +14,16 @@ async fn resume() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; - let initial_message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let initial_message = + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; let later_messages = vec![ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, ]; // Call the endpoint @@ -75,8 +76,8 @@ async fn resume() { // This test verifies a real bug I hit developing the vector-of-sequences // approach to resuming events. A small omission caused the event IDs in a -// resumed stream to _omit_ channels that were in the original stream until -// those channels also appeared in the resumed stream. +// resumed stream to _omit_ conversations that were in the original stream +// until those conversations also appeared in the resumed stream. // // Clients would see something like // * In the original stream, Cfoo=5,Cbar=8 @@ -84,8 +85,8 @@ async fn resume() { // // Disconnecting and reconnecting a second time, using event IDs from that // initial period of the first resume attempt, would then cause the second -// resume attempt to restart all other channels from the beginning, and not -// from where the first disconnection happened. +// resume attempt to restart all other conversations from the beginning, and +// not from where the first disconnection happened. // // As we have switched to a single global event sequence number, this scenario // can no longer arise, but this test is preserved because the actual behaviour @@ -97,8 +98,8 @@ async fn serial_resume() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel_a = fixtures::channel::create(&app, &fixtures::now()).await; - let channel_b = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation_a = fixtures::conversation::create(&app, &fixtures::now()).await; + let conversation_b = fixtures::conversation::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; // Call the endpoint @@ -107,8 +108,8 @@ async fn serial_resume() { let resume_at = { let initial_messages = [ - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_b, &sender, &fixtures::now()).await, ]; // First subscription @@ -148,11 +149,11 @@ async fn serial_resume() { // Resume after disconnect let resume_at = { let resume_messages = [ - // Note that channel_b does not appear here. The buggy behaviour - // would be masked if channel_b happened to send a new message + // Note that conversation_b does not appear here. The buggy behaviour + // would be masked if conversation_b happened to send a new message // into the resumed event stream. - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_a, &sender, &fixtures::now()).await, ]; // Second subscription @@ -190,12 +191,12 @@ async fn serial_resume() { // Resume after disconnect a second time { - // At this point, we can send on either channel and demonstrate the - // problem. The resume point should before both of these messages, but - // after _all_ prior messages. + // At this point, we can send on either conversation and demonstrate + // the problem. The resume point should before both of these messages, + // but after _all_ prior messages. let final_messages = [ - fixtures::message::send(&app, &channel_a, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel_b, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_a, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation_b, &sender, &fixtures::now()).await, ]; // Third subscription diff --git a/src/event/handlers/stream/test/token.rs b/src/event/handlers/stream/test/token.rs index e32b489..5af07a0 100644 --- a/src/event/handlers/stream/test/token.rs +++ b/src/event/handlers/stream/test/token.rs @@ -9,7 +9,7 @@ async fn terminates_on_token_expiry() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; @@ -37,9 +37,9 @@ async fn terminates_on_token_expiry() { // These should not be delivered. let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, ]; events @@ -56,7 +56,7 @@ async fn terminates_on_logout() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; @@ -83,9 +83,9 @@ async fn terminates_on_logout() { // These should not be delivered. let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, ]; events @@ -102,7 +102,7 @@ async fn terminates_on_password_change() { // Set up the environment let app = fixtures::scratch_app().await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; let sender = fixtures::user::create(&app, &fixtures::now()).await; let resume_point = fixtures::boot::resume_point(&app).await; @@ -133,9 +133,9 @@ async fn terminates_on_password_change() { // These should not be delivered. let messages = [ - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, - fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, + fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await, ]; events diff --git a/src/event/mod.rs b/src/event/mod.rs index 6657243..f41dc9c 100644 --- a/src/event/mod.rs +++ b/src/event/mod.rs @@ -2,7 +2,7 @@ use std::time::Duration; use axum::response::sse::{self, KeepAlive}; -use crate::{channel, message, user}; +use crate::{conversation, message, user}; pub mod app; mod broadcaster; @@ -20,7 +20,7 @@ pub use self::{ #[serde(tag = "type", rename_all = "snake_case")] pub enum Event { User(user::Event), - Channel(channel::Event), + Conversation(conversation::Event), Message(message::Event), } @@ -38,7 +38,7 @@ impl Sequenced for Event { fn instant(&self) -> Instant { match self { Self::User(event) => event.instant(), - Self::Channel(event) => event.instant(), + Self::Conversation(event) => event.instant(), Self::Message(event) => event.instant(), } } @@ -50,9 +50,9 @@ impl From<user::Event> for Event { } } -impl From<channel::Event> for Event { - fn from(event: channel::Event) -> Self { - Self::Channel(event) +impl From<conversation::Event> for Event { + fn from(event: conversation::Event) -> Self { + Self::Conversation(event) } } diff --git a/src/expire.rs b/src/expire.rs index 1427a8d..4177a53 100644 --- a/src/expire.rs +++ b/src/expire.rs @@ -6,7 +6,7 @@ use axum::{ use crate::{app::App, clock::RequestedAt, error::Internal}; -// Expires messages and channels before each request. +// Expires messages and conversations before each request. pub async fn middleware( State(app): State<App>, RequestedAt(expired_at): RequestedAt, @@ -17,7 +17,7 @@ pub async fn middleware( app.invites().expire(&expired_at).await?; app.messages().expire(&expired_at).await?; app.messages().purge(&expired_at).await?; - app.channels().expire(&expired_at).await?; - app.channels().purge(&expired_at).await?; + app.conversations().expire(&expired_at).await?; + app.conversations().purge(&expired_at).await?; Ok(next.run(req).await) } @@ -5,9 +5,9 @@ mod app; mod boot; mod broadcast; -mod channel; pub mod cli; mod clock; +mod conversation; mod db; mod error; mod event; diff --git a/src/message/app.rs b/src/message/app.rs index 9792c8f..bdc2164 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -4,8 +4,8 @@ use sqlx::sqlite::SqlitePool; use super::{Body, Id, Message, repo::Provider as _}; use crate::{ - channel::{self, repo::Provider as _}, clock::DateTime, + conversation::{self, repo::Provider as _}, db::NotFound as _, event::{Broadcaster, Event, Sequence, repo::Provider as _}, name, @@ -24,23 +24,29 @@ impl<'a> Messages<'a> { pub async fn send( &self, - channel: &channel::Id, + conversation: &conversation::Id, sender: &User, sent_at: &DateTime, body: &Body, ) -> Result<Message, SendError> { - let to_not_found = || SendError::ChannelNotFound(channel.clone()); - let to_deleted = || SendError::ChannelDeleted(channel.clone()); + let to_not_found = || SendError::ConversationNotFound(conversation.clone()); + let to_deleted = || SendError::ConversationDeleted(conversation.clone()); let mut tx = self.db.begin().await?; - let channel = tx.channels().by_id(channel).await.not_found(to_not_found)?; + let conversation = tx + .conversations() + .by_id(conversation) + .await + .not_found(to_not_found)?; // Ordering: don't bother allocating a sequence number before we know the channel might // exist. let sent = tx.sequence().next(sent_at).await?; - let channel = channel.as_of(sent).ok_or_else(to_deleted)?; - - let message = tx.messages().create(&channel, sender, &sent, body).await?; + let conversation = conversation.as_of(sent).ok_or_else(to_deleted)?; + let message = tx + .messages() + .create(&conversation, sender, &sent, body) + .await?; tx.commit().await?; self.events @@ -128,19 +134,19 @@ impl<'a> Messages<'a> { #[derive(Debug, thiserror::Error)] pub enum SendError { - #[error("channel {0} not found")] - ChannelNotFound(channel::Id), - #[error("channel {0} deleted")] - ChannelDeleted(channel::Id), + #[error("conversation {0} not found")] + ConversationNotFound(conversation::Id), + #[error("conversation {0} deleted")] + ConversationDeleted(conversation::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; +impl From<conversation::repo::LoadError> for SendError { + fn from(error: conversation::repo::LoadError) -> Self { + use conversation::repo::LoadError; match error { LoadError::Database(error) => error.into(), LoadError::Name(error) => error.into(), diff --git a/src/message/handlers/delete/test.rs b/src/message/handlers/delete/test.rs index f567eb7..371c7bf 100644 --- a/src/message/handlers/delete/test.rs +++ b/src/message/handlers/delete/test.rs @@ -9,8 +9,9 @@ pub async fn delete_message() { let app = fixtures::scratch_app().await; let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender.user, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; + let message = + fixtures::message::send(&app, &conversation, &sender.user, &fixtures::now()).await; // Send the request @@ -70,8 +71,8 @@ pub async fn delete_deleted() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; app.messages() .delete(&sender, &message.id, &fixtures::now()) @@ -101,8 +102,8 @@ pub async fn delete_expired() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; app.messages() .expire(&fixtures::now()) @@ -132,8 +133,8 @@ pub async fn delete_purged() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::ancient()).await; - let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::ancient()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::ancient()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::ancient()).await; app.messages() .expire(&fixtures::old()) @@ -168,8 +169,8 @@ pub async fn delete_not_sender() { let app = fixtures::scratch_app().await; let sender = fixtures::user::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; + let conversation = fixtures::conversation::create(&app, &fixtures::now()).await; + let message = fixtures::message::send(&app, &conversation, &sender, &fixtures::now()).await; // Send the request diff --git a/src/message/repo.rs b/src/message/repo.rs index e753134..9b65a67 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -2,8 +2,8 @@ use sqlx::{SqliteConnection, Transaction, sqlite::Sqlite}; use super::{Body, History, Id, snapshot::Message}; use crate::{ - channel::{self, Channel}, clock::DateTime, + conversation::{self, Conversation}, event::{Instant, Sequence}, user::{self, User}, }; @@ -23,7 +23,7 @@ pub struct Messages<'t>(&'t mut SqliteConnection); impl Messages<'_> { pub async fn create( &mut self, - channel: &Channel, + conversation: &Conversation, sender: &User, sent: &Instant, body: &Body, @@ -33,18 +33,18 @@ impl Messages<'_> { let message = sqlx::query!( r#" insert into message - (id, channel, sender, sent_at, sent_sequence, body, last_sequence) + (id, conversation, sender, sent_at, sent_sequence, body, last_sequence) values ($1, $2, $3, $4, $5, $6, $7) returning id as "id: Id", - channel as "channel: channel::Id", + conversation as "conversation: conversation::Id", sender as "sender: user::Id", sent_at as "sent_at: DateTime", sent_sequence as "sent_sequence: Sequence", body as "body: Body" "#, id, - channel.id, + conversation.id, sender.id, sent.at, sent.sequence, @@ -54,7 +54,7 @@ impl Messages<'_> { .map(|row| History { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), - channel: row.channel, + conversation: row.conversation, sender: row.sender, id: row.id, body: row.body.unwrap_or_default(), @@ -68,12 +68,15 @@ impl Messages<'_> { Ok(message) } - pub async fn live(&mut self, channel: &channel::History) -> Result<Vec<History>, sqlx::Error> { - let channel_id = channel.id(); + pub async fn live( + &mut self, + conversation: &conversation::History, + ) -> Result<Vec<History>, sqlx::Error> { + let conversation_id = conversation.id(); let messages = sqlx::query!( r#" select - message.channel as "channel: channel::Id", + message.conversation as "conversation: conversation::Id", message.sender as "sender: user::Id", id as "id: Id", message.body as "body: Body", @@ -84,15 +87,15 @@ impl Messages<'_> { from message left join message_deleted as deleted using (id) - where message.channel = $1 + where message.conversation = $1 and deleted.id is null "#, - channel_id, + conversation_id, ) .map(|row| History { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), - channel: row.channel, + conversation: row.conversation, sender: row.sender, id: row.id, body: row.body.unwrap_or_default(), @@ -110,7 +113,7 @@ impl Messages<'_> { let messages = sqlx::query!( r#" select - message.channel as "channel: channel::Id", + message.conversation as "conversation: conversation::Id", message.sender as "sender: user::Id", message.id as "id: Id", message.body as "body: Body", @@ -129,7 +132,7 @@ impl Messages<'_> { .map(|row| History { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), - channel: row.channel, + conversation: row.conversation, sender: row.sender, id: row.id, body: row.body.unwrap_or_default(), @@ -147,7 +150,7 @@ impl Messages<'_> { let message = sqlx::query!( r#" select - message.channel as "channel: channel::Id", + message.conversation as "conversation: conversation::Id", message.sender as "sender: user::Id", id as "id: Id", message.body as "body: Body", @@ -165,7 +168,7 @@ impl Messages<'_> { .map(|row| History { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), - channel: row.channel, + conversation: row.conversation, sender: row.sender, id: row.id, body: row.body.unwrap_or_default(), @@ -200,7 +203,7 @@ impl Messages<'_> { // Small social responsibility hack here: when a message is deleted, its body is // retconned to have been the empty string. Someone reading the event stream - // afterwards, or looking at messages in the channel, cannot retrieve the + // afterwards, or looking at messages in the conversation, cannot retrieve the // "deleted" message by ignoring the deletion event. sqlx::query!( r#" @@ -252,7 +255,7 @@ impl Messages<'_> { r#" select id as "id: Id", - message.channel as "channel: channel::Id", + message.conversation as "conversation: conversation::Id", message.sender as "sender: user::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", @@ -271,7 +274,7 @@ impl Messages<'_> { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), id: row.id, - channel: row.channel, + conversation: row.conversation, sender: row.sender, body: row.body.unwrap_or_default(), deleted_at: row.deleted_at, @@ -289,7 +292,7 @@ impl Messages<'_> { r#" select id as "id: Id", - message.channel as "channel: channel::Id", + message.conversation as "conversation: conversation::Id", message.sender as "sender: user::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", @@ -306,7 +309,7 @@ impl Messages<'_> { .map(|row| History { message: Message { sent: Instant::new(row.sent_at, row.sent_sequence), - channel: row.channel, + conversation: row.conversation, sender: row.sender, id: row.id, body: row.body.unwrap_or_default(), diff --git a/src/message/snapshot.rs b/src/message/snapshot.rs index ac067f7..12d4daa 100644 --- a/src/message/snapshot.rs +++ b/src/message/snapshot.rs @@ -2,13 +2,13 @@ use super::{ Body, Id, event::{Event, Sent}, }; -use crate::{channel, clock::DateTime, event::Instant, user}; +use crate::{clock::DateTime, conversation, event::Instant, user}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Message { #[serde(flatten)] pub sent: Instant, - pub channel: channel::Id, + pub conversation: conversation::Id, pub sender: user::Id, pub id: Id, pub body: Body, diff --git a/src/routes.rs b/src/routes.rs index 1e66582..49d9fb6 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,7 +4,7 @@ use axum::{ routing::{delete, get, post}, }; -use crate::{app::App, boot, channel, event, expire, invite, message, setup, ui, user}; +use crate::{app::App, boot, conversation, event, expire, invite, message, setup, ui, user}; pub fn routes(app: &App) -> Router<App> { // UI routes that can be accessed before the administrator completes setup. @@ -15,7 +15,7 @@ pub fn routes(app: &App) -> Router<App> { // UI routes that require the administrator to complete setup first. let ui_setup_required = Router::new() .route("/", get(ui::handlers::index)) - .route("/ch/{channel}", get(ui::handlers::channel)) + .route("/c/{conversation}", get(ui::handlers::conversation)) .route("/invite/{invite}", get(ui::handlers::invite)) .route("/login", get(ui::handlers::login)) .route("/me", get(ui::handlers::me)) @@ -29,9 +29,15 @@ pub fn routes(app: &App) -> Router<App> { .route("/api/auth/login", post(user::handlers::login)) .route("/api/auth/logout", post(user::handlers::logout)) .route("/api/boot", get(boot::handlers::boot)) - .route("/api/channels", post(channel::handlers::create)) - .route("/api/channels/{channel}", post(channel::handlers::send)) - .route("/api/channels/{channel}", delete(channel::handlers::delete)) + .route("/api/conversations", post(conversation::handlers::create)) + .route( + "/api/conversations/{conversation}", + post(conversation::handlers::send), + ) + .route( + "/api/conversations/{conversation}", + delete(conversation::handlers::delete), + ) .route("/api/events", get(event::handlers::stream)) .route("/api/invite", post(invite::handlers::issue)) .route("/api/invite/{invite}", get(invite::handlers::get)) diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/conversation.rs index 98048f2..fb2f58d 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/conversation.rs @@ -7,17 +7,17 @@ use rand; use crate::{ app::App, - channel::{self, Channel}, clock::RequestedAt, + conversation::{self, Conversation}, name::Name, }; -pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { +pub async fn create(app: &App, created_at: &RequestedAt) -> Conversation { let name = propose(); - app.channels() + app.conversations() .create(&name, created_at) .await - .expect("should always succeed if the channel is actually new") + .expect("should always succeed if the conversation is actually new") } pub fn propose() -> Name { @@ -33,6 +33,6 @@ faker_impl_from_templates! { NameTemplate; "{} {}", CityName, FullName; } -pub fn fictitious() -> channel::Id { - channel::Id::generate() +pub fn fictitious() -> conversation::Id { + conversation::Id::generate() } diff --git a/src/test/fixtures/event/mod.rs b/src/test/fixtures/event/mod.rs index 691cdeb..08b17e7 100644 --- a/src/test/fixtures/event/mod.rs +++ b/src/test/fixtures/event/mod.rs @@ -2,9 +2,9 @@ use crate::event::Event; pub mod stream; -pub fn channel(event: Event) -> Option<crate::channel::Event> { +pub fn conversation(event: Event) -> Option<crate::conversation::Event> { match event { - Event::Channel(channel) => Some(channel), + Event::Conversation(conversation) => Some(conversation), _ => None, } } @@ -23,8 +23,8 @@ pub fn user(event: Event) -> Option<crate::user::Event> { } } -pub mod channel { - use crate::channel::{Event, event}; +pub mod conversation { + use crate::conversation::{Event, event}; pub fn created(event: Event) -> Option<event::Created> { match event { diff --git a/src/test/fixtures/event/stream.rs b/src/test/fixtures/event/stream.rs index 6c2a1bf..5b3621d 100644 --- a/src/test/fixtures/event/stream.rs +++ b/src/test/fixtures/event/stream.rs @@ -2,8 +2,8 @@ use std::future::{self, Ready}; use crate::{event::Event, test::fixtures::event}; -pub fn channel(event: Event) -> Ready<Option<crate::channel::Event>> { - future::ready(event::channel(event)) +pub fn conversation(event: Event) -> Ready<Option<crate::conversation::Event>> { + future::ready(event::conversation(event)) } pub fn message(event: Event) -> Ready<Option<crate::message::Event>> { @@ -14,20 +14,20 @@ pub fn user(event: Event) -> Ready<Option<crate::user::Event>> { future::ready(event::user(event)) } -pub mod channel { +pub mod conversation { use std::future::{self, Ready}; use crate::{ - channel::{Event, event}, - test::fixtures::event::channel, + conversation::{Event, event}, + test::fixtures::event::conversation, }; pub fn created(event: Event) -> Ready<Option<event::Created>> { - future::ready(channel::created(event)) + future::ready(conversation::created(event)) } pub fn deleted(event: Event) -> Ready<Option<event::Deleted>> { - future::ready(channel::deleted(event)) + future::ready(conversation::deleted(event)) } } diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 2254915..03f8072 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -2,19 +2,24 @@ use faker_rand::lorem::Paragraphs; use crate::{ app::App, - channel::Channel, clock::RequestedAt, + conversation::Conversation, message::{self, Body, Message}, user::User, }; -pub async fn send(app: &App, channel: &Channel, sender: &User, sent_at: &RequestedAt) -> Message { +pub async fn send( + app: &App, + conversation: &Conversation, + sender: &User, + sent_at: &RequestedAt, +) -> Message { let body = propose(); app.messages() - .send(&channel.id, sender, sent_at, &body) + .send(&conversation.id, sender, sent_at, &body) .await - .expect("should succeed if the channel exists") + .expect("should succeed if the conversation exists") } pub fn propose() -> Body { diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 418bdb5..87d3fa1 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -3,7 +3,7 @@ use chrono::{TimeDelta, Utc}; use crate::{app::App, clock::RequestedAt, db}; pub mod boot; -pub mod channel; +pub mod conversation; pub mod cookie; pub mod event; pub mod future; diff --git a/src/ui/handlers/channel.rs b/src/ui/handlers/conversation.rs index d3199dd..f1bb319 100644 --- a/src/ui/handlers/channel.rs +++ b/src/ui/handlers/conversation.rs @@ -5,7 +5,7 @@ use axum::{ use crate::{ app::App, - channel::{self, app}, + conversation::{self, app}, error::Internal, token::extract::Identity, ui::{ @@ -17,17 +17,20 @@ use crate::{ pub async fn handler( State(app): State<App>, identity: Option<Identity>, - Path(channel): Path<channel::Id>, + Path(conversation): Path<conversation::Id>, ) -> Result<Asset, Error> { let _ = identity.ok_or(Error::NotLoggedIn)?; - app.channels().get(&channel).await.map_err(Error::from)?; + app.conversations() + .get(&conversation) + .await + .map_err(Error::from)?; Assets::index().map_err(Error::Internal) } #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("channel not found")] + #[error("conversation not found")] NotFound, #[error("not logged in")] NotLoggedIn, diff --git a/src/ui/handlers/mod.rs b/src/ui/handlers/mod.rs index 5bfd0d6..ed0c14e 100644 --- a/src/ui/handlers/mod.rs +++ b/src/ui/handlers/mod.rs @@ -1,5 +1,5 @@ mod asset; -mod channel; +mod conversation; mod index; mod invite; mod login; @@ -7,7 +7,7 @@ mod me; mod setup; pub use asset::handler as asset; -pub use channel::handler as channel; +pub use conversation::handler as conversation; pub use index::handler as index; pub use invite::handler as invite; pub use login::handler as login; @@ -5,14 +5,14 @@ @import url('styles/app-bar.css'); @import url('styles/app-layout.css'); @import url('styles/sidebar.css'); -@import url('styles/active-channel.css'); +@import url('styles/active-conversation.css'); @import url('styles/messages.css'); @import url('styles/textarea.css'); @import url('styles/forms.css'); @import url('styles/invites.css'); body { - background-color: var(--colour-active-channel-bg); + background-color: var(--colour-active-conversation-bg); color: var(--dark-text); font-family: 'Roboto', sans-serif; } @@ -21,7 +21,7 @@ hr { width: 90%; } -.no-active-channel { +.no-active-conversation { display: table; height: 100%; width: 100%; diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index 397638c..ac707a5 100644 --- a/ui/lib/apiServer.js +++ b/ui/lib/apiServer.js @@ -1,7 +1,6 @@ import axios from 'axios'; import * as r from './retry.js'; -import { timedDelay } from './retry.js'; export const apiServer = axios.create({ baseURL: '/api/', @@ -27,12 +26,12 @@ export async function changePassword(password, to) { return await apiServer.post('/password', { password, to }).catch(responseError); } -export async function createChannel(name) { - return await apiServer.post('/channels', { name }).catch(responseError); +export async function createConversation(name) { + return await apiServer.post('/conversations', { name }).catch(responseError); } -export async function postToChannel(channelId, body) { - return await apiServer.post(`/channels/${channelId}`, { body }).catch(responseError); +export async function sendToConversation(conversationId, body) { + return await apiServer.post(`/conversations/${conversationId}`, { body }).catch(responseError); } export async function deleteMessage(messageId) { diff --git a/ui/lib/components/ChannelList.svelte b/ui/lib/components/ChannelList.svelte deleted file mode 100644 index 51dd6cf..0000000 --- a/ui/lib/components/ChannelList.svelte +++ /dev/null @@ -1,13 +0,0 @@ -<script> - import Channel from './Channel.svelte'; - - let { channels, active } = $props(); -</script> - -<nav class="list-nav"> - <ul> - {#each channels as channel} - <Channel {...channel} active={active === channel.id} /> - {/each} - </ul> -</nav> diff --git a/ui/lib/components/Channel.svelte b/ui/lib/components/Conversation.svelte index 4f908d2..9004e50 100644 --- a/ui/lib/components/Channel.svelte +++ b/ui/lib/components/Conversation.svelte @@ -3,7 +3,7 @@ </script> <li class:active> - <a href="/ch/{id}"> + <a href="/c/{id}"> {#if hasUnreads} <span class="badge has-unreads">❦</span> {:else} diff --git a/ui/lib/components/ConversationList.svelte b/ui/lib/components/ConversationList.svelte new file mode 100644 index 0000000..71332e0 --- /dev/null +++ b/ui/lib/components/ConversationList.svelte @@ -0,0 +1,17 @@ +<script> + import Conversation from './Conversation.svelte'; + + let { conversations, active } = $props(); + + function isActive(conversation) { + return active === conversation.id; + } +</script> + +<nav class="list-nav"> + <ul> + {#each conversations as conversation} + <Conversation {...conversation} active={isActive(conversation)} /> + {/each} + </ul> +</nav> diff --git a/ui/lib/components/CreateChannelForm.svelte b/ui/lib/components/CreateConversationForm.svelte index 471c2b7..e390a78 100644 --- a/ui/lib/components/CreateChannelForm.svelte +++ b/ui/lib/components/CreateConversationForm.svelte @@ -1,5 +1,5 @@ <script> - let { createChannel = async (name) => {} } = $props(); + let { createConversation = async (name) => {} } = $props(); let name = $state(''); let disabled = $state(false); @@ -8,7 +8,7 @@ event.preventDefault(); disabled = true; try { - await createChannel(name); + await createConversation(name); event.target.reset(); } finally { disabled = false; @@ -17,6 +17,6 @@ </script> <form {onsubmit}> - <input type="text" placeholder="create channel" bind:value={name} {disabled} /> + <input type="text" placeholder="start a conversation" bind:value={name} {disabled} /> <button type="submit">➕</button> </form> diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js index 183f8ff..c4e2324 100644 --- a/ui/lib/outbox.svelte.js +++ b/ui/lib/outbox.svelte.js @@ -4,9 +4,9 @@ import * as msg from './state/remote/messages.svelte.js'; import * as api from './apiServer.js'; import * as md from './markdown.js'; -class PostToChannel { - constructor(channel, body) { - this.channel = channel; +class SendToConversation { + constructor(conversation, body) { + this.conversation = conversation; this.body = body; this.at = DateTime.now(); this.renderedBody = md.render(body); @@ -16,7 +16,7 @@ class PostToChannel { return { id: null, at: this.at, - channel: this.channel, + conversation: this.conversation, sender, body: this.body, renderedBody: this.renderedBody, @@ -24,7 +24,7 @@ class PostToChannel { } async send() { - return await api.retry(() => api.postToChannel(this.channel, this.body)); + return await api.retry(() => api.sendToConversation(this.conversation, this.body)); } } @@ -38,19 +38,19 @@ class DeleteMessage { } } -class CreateChannel { +class CreateConversation { constructor(name) { this.name = name; } async send() { - return await api.retry(() => api.createChannel(this.name)); + return await api.retry(() => api.createConversation(this.name)); } } export class Outbox { pending = $state([]); - messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel)); + messages = $derived(this.pending.filter((operation) => operation instanceof SendToConversation)); deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage)); static empty() { @@ -66,12 +66,12 @@ export class Outbox { this.start(); } - createChannel(name) { - this.enqueue(new CreateChannel(name)); + createConversation(name) { + this.enqueue(new CreateConversation(name)); } - postToChannel(channel, body) { - this.enqueue(new PostToChannel(channel, body)); + sendToConversation(conversationId, body) { + this.enqueue(new SendToConversation(conversationId, body)); } deleteMessage(messageId) { diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js index 838401c..4430e8a 100644 --- a/ui/lib/session.svelte.js +++ b/ui/lib/session.svelte.js @@ -4,20 +4,20 @@ import { goto } from '$app/navigation'; import * as api from './apiServer.js'; import * as r from './state/remote/state.svelte.js'; -import * as l from './state/local/channels.svelte.js'; +import * as l from './state/local/conversations.svelte.js'; import { Watchdog } from './watchdog.js'; import { DateTime } from 'luxon'; -class Channel { +class Conversation { static fromRemote({ at, id, name }, messages, meta) { const sentAt = messages - .filter((message) => message.channel === id) + .filter((message) => message.conversation === id) .map((message) => message.at); const lastEventAt = DateTime.max(at, ...sentAt); const lastReadAt = meta.get(id)?.lastReadAt; const hasUnreads = lastReadAt === undefined || lastEventAt > lastReadAt; - return new Channel({ at, id, name, hasUnreads }); + return new Conversation({ at, id, name, hasUnreads }); } constructor({ at, id, name, hasUnreads }) { @@ -29,21 +29,21 @@ class Channel { } class Message { - static fromRemote({ id, at, channel, sender, body, renderedBody }, users) { + static fromRemote({ id, at, conversation, sender, body, renderedBody }, users) { return new Message({ id, at, - channel, + conversation, sender: users.get(sender), body, renderedBody, }); } - constructor({ id, at, channel, sender, body, renderedBody }) { + constructor({ id, at, conversation, sender, body, renderedBody }) { this.id = id; this.at = at; - this.channel = channel; + this.conversation = conversation; this.sender = sender; this.body = body; this.renderedBody = renderedBody; @@ -58,9 +58,9 @@ class Session { messages = $derived( this.remote.messages.all.map((message) => Message.fromRemote(message, this.users)), ); - channels = $derived( - this.remote.channels.all.map((channel) => - Channel.fromRemote(channel, this.messages, this.local.all), + conversations = $derived( + this.remote.conversations.all.map((conversation) => + Conversation.fromRemote(conversation, this.messages, this.local.all), ), ); @@ -71,7 +71,7 @@ class Session { heartbeat, events, }); - const local = l.Channels.fromLocalStorage(); + const local = l.Conversations.fromLocalStorage(); return new Session(remote, local); } @@ -109,7 +109,7 @@ class Session { onMessage(message) { const event = JSON.parse(message.data); this.remote.onEvent(event); - this.local.retainChannels(this.remote.channels.all); + this.local.retainConversations(this.remote.conversations.all); this.watchdog.reset(this.heartbeatMillis()); } diff --git a/ui/lib/state/local/channels.svelte.js b/ui/lib/state/local/channels.svelte.js deleted file mode 100644 index 669aa1e..0000000 --- a/ui/lib/state/local/channels.svelte.js +++ /dev/null @@ -1,118 +0,0 @@ -import { DateTime } from 'luxon'; -import { SvelteMap } from 'svelte/reactivity'; - -import * as iter from '$lib/iterator.js'; - -export const STORE_KEY_CHANNELS_DATA = 'pilcrow:channelsData'; - -class Channel { - draft = $state(); - lastReadAt = $state(null); - scrollPosition = $state(null); - - static fromStored({ draft, lastReadAt, scrollPosition }) { - return new Channel({ - draft, - lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt), - scrollPosition, - }); - } - - constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) { - this.draft = draft; - this.lastReadAt = lastReadAt; - this.scrollPosition = scrollPosition; - } - - toStored() { - const { draft, lastReadAt, scrollPosition } = this; - return { - draft, - lastReadAt: lastReadAt?.toISO(), - scrollPosition, - }; - } -} - -export class Channels { - // Store channelId -> { draft = '', lastReadAt = null, scrollPosition = null } - all = $state(); - - static fromLocalStorage() { - const stored = localStorage.getItem(STORE_KEY_CHANNELS_DATA); - if (stored !== null) { - return Channels.fromStored(JSON.parse(stored)); - } - return Channels.empty(); - } - - static fromStored(stored) { - const loaded = Object.keys(stored).map((channelId) => [ - channelId, - Channel.fromStored(stored[channelId]), - ]); - const all = new SvelteMap(loaded); - return new Channels({ all }); - } - - static empty() { - return new Channels({ all: new SvelteMap() }); - } - - constructor({ all }) { - this.all = all; - } - - channel(channelId) { - let channel = this.all.get(channelId); - if (channel === undefined) { - channel = new Channel(); - this.all.set(channelId, channel); - } - return channel; - } - - updateLastReadAt(channelId, at) { - const channel = this.channel(channelId); - // Do it this way, rather than with Math.max tricks, to avoid assignment - // when we don't need it, to minimize reactive changes: - if (channel.lastReadAt === null || at > channel.lastReadAt) { - channel.lastReadAt = at; - this.save(); - } - } - - retainChannels(channels) { - const channelIds = channels.map((channel) => channel.id); - const retain = new Set(channelIds); - for (const channelId of Array.from(this.all.keys())) { - if (!retain.has(channelId)) { - this.all.delete(channelId); - } - } - this.save(); - } - - toStored() { - return iter.reduce( - this.all.entries(), - (stored, [channelId, channel]) => ({ - ...stored, - [channelId]: channel.toStored(), - }), - {}, - ); - } - - save() { - let stored = this.toStored(); - localStorage.setItem(STORE_KEY_CHANNELS_DATA, JSON.stringify(stored)); - } -} - -function objectMap(object, mapFn) { - return Object.keys(object).reduce((result, key) => { - result[key] = mapFn(object[key]); - return result; - }, {}); -} diff --git a/ui/lib/state/local/conversations.svelte.js b/ui/lib/state/local/conversations.svelte.js new file mode 100644 index 0000000..835c237 --- /dev/null +++ b/ui/lib/state/local/conversations.svelte.js @@ -0,0 +1,119 @@ +import { DateTime } from 'luxon'; +import { SvelteMap } from 'svelte/reactivity'; + +import * as iter from '$lib/iterator.js'; + +// Conversations were called "channels" in previous iterations. Support loading +// data saved under that name to prevent the change from resetting everyone's +// unread tracking. +export const STORE_KEY_CHANNELS = 'pilcrow:channelsData'; +export const STORE_KEY_CONVERSATIONS = 'pilcrow:conversations'; + +class Conversation { + draft = $state(); + lastReadAt = $state(null); + scrollPosition = $state(null); + + static fromStored({ draft, lastReadAt, scrollPosition }) { + return new Conversation({ + draft, + lastReadAt: lastReadAt == null ? null : DateTime.fromISO(lastReadAt), + scrollPosition, + }); + } + + constructor({ draft = '', lastReadAt = null, scrollPosition = null } = {}) { + this.draft = draft; + this.lastReadAt = lastReadAt; + this.scrollPosition = scrollPosition; + } + + toStored() { + const { draft, lastReadAt, scrollPosition } = this; + return { + draft, + lastReadAt: lastReadAt?.toISO(), + scrollPosition, + }; + } +} + +export class Conversations { + // Store conversationId -> { draft = '', lastReadAt = null, scrollPosition = null } + all = $state(); + + static fromLocalStorage() { + const stored = + localStorage.getItem(STORE_KEY_CONVERSATIONS) ?? localStorage.getItem(STORE_KEY_CHANNELS); + if (stored !== null) { + return Conversations.fromStored(JSON.parse(stored)); + } + return Conversations.empty(); + } + + static fromStored(stored) { + const loaded = Object.keys(stored).map((conversationId) => [ + conversationId, + Conversation.fromStored(stored[conversationId]), + ]); + const all = new SvelteMap(loaded); + return new Conversations({ all }); + } + + static empty() { + return new Conversations({ all: new SvelteMap() }); + } + + constructor({ all }) { + this.all = all; + } + + conversation(conversationId) { + let conversation = this.all.get(conversationId); + if (conversation === undefined) { + conversation = new Conversation(); + this.all.set(conversationId, conversation); + } + return conversation; + } + + updateLastReadAt(conversationId, at) { + const conversation = this.conversation(conversationId); + // Do it this way, rather than with Math.max tricks, to avoid assignment + // when we don't need it, to minimize reactive changes: + if (conversation.lastReadAt === null || at > conversation.lastReadAt) { + conversation.lastReadAt = at; + this.save(); + } + } + + retainConversations(conversations) { + const conversationIds = conversations.map((conversation) => conversation.id); + const retain = new Set(conversationIds); + for (const conversationId of Array.from(this.all.keys())) { + if (!retain.has(conversationId)) { + this.all.delete(conversationId); + } + } + this.save(); + } + + toStored() { + return iter.reduce( + this.all.entries(), + (stored, [conversationId, conversation]) => ({ + ...stored, + [conversationId]: conversation.toStored(), + }), + {}, + ); + } + + save() { + let stored = this.toStored(); + localStorage.setItem(STORE_KEY_CONVERSATIONS, JSON.stringify(stored)); + // If we were able to save the data under `pilcrow:conversations`, then remove the old data; + // it is no longer needed and wouldn't be loaded anyways. + localStorage.removeItem(STORE_KEY_CHANNELS); + } +} diff --git a/ui/lib/state/remote/channels.svelte.js b/ui/lib/state/remote/conversations.svelte.js index 1e40075..79868f4 100644 --- a/ui/lib/state/remote/channels.svelte.js +++ b/ui/lib/state/remote/conversations.svelte.js @@ -1,8 +1,8 @@ import { DateTime } from 'luxon'; -class Channel { +class Conversation { static boot({ at, id, name }) { - return new Channel({ + return new Conversation({ at: DateTime.fromISO(at), id, name, @@ -16,14 +16,14 @@ class Channel { } } -export class Channels { +export class Conversations { all = $state([]); add({ at, id, name }) { - this.all.push(Channel.boot({ at, id, name })); + this.all.push(Conversation.boot({ at, id, name })); } remove(id) { - this.all = this.all.filter((channel) => channel.id !== id); + this.all = this.all.filter((conversation) => conversation.id !== id); } } diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js index 1be001b..852f29e 100644 --- a/ui/lib/state/remote/messages.svelte.js +++ b/ui/lib/state/remote/messages.svelte.js @@ -2,21 +2,21 @@ import { DateTime } from 'luxon'; import { render } from '$lib/markdown.js'; class Message { - static boot({ id, at, channel, sender, body }) { + static boot({ id, at, conversation, sender, body }) { return new Message({ id, at: DateTime.fromISO(at), - channel, + conversation, sender, body, renderedBody: render(body), }); } - constructor({ id, at, channel, sender, body, renderedBody }) { + constructor({ id, at, conversation, sender, body, renderedBody }) { this.id = id; this.at = at; - this.channel = channel; + this.conversation = conversation; this.sender = sender; this.body = body; this.renderedBody = renderedBody; @@ -26,8 +26,8 @@ class Message { export class Messages { all = $state([]); - add({ id, at, channel, sender, body }) { - const message = Message.boot({ id, at, channel, sender, body }); + add({ id, at, conversation, sender, body }) { + const message = Message.boot({ id, at, conversation, sender, body }); this.all.push(message); } diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js index fb46489..3d65e4a 100644 --- a/ui/lib/state/remote/state.svelte.js +++ b/ui/lib/state/remote/state.svelte.js @@ -1,11 +1,11 @@ import { User, Users } from './users.svelte.js'; -import { Channels } from './channels.svelte.js'; +import { Conversations } from './conversations.svelte.js'; import { Messages } from './messages.svelte.js'; export class State { currentUser = $state(); users = $state(new Users()); - channels = $state(new Channels()); + conversations = $state(new Conversations()); messages = $state(new Messages()); static boot({ currentUser, heartbeat, resumePoint, events }) { @@ -30,8 +30,8 @@ export class State { // Heartbeats are actually completely ignored here. They're handled in `Session`, but not as a // special case; _any_ event is a heartbeat event. switch (event.type) { - case 'channel': - return this.onChannelEvent(event); + case 'conversation': + return this.onConversationEvent(event); case 'user': return this.onUserEvent(event); case 'message': @@ -39,23 +39,23 @@ export class State { } } - onChannelEvent(event) { + onConversationEvent(event) { switch (event.event) { case 'created': - return this.onChannelCreated(event); + return this.onConversationCreated(event); case 'deleted': - return this.onChannelDeleted(event); + return this.onConversationDeleted(event); } } - onChannelCreated(event) { + onConversationCreated(event) { const { id, name } = event; - this.channels.add({ id, name }); + this.conversations.add({ id, name }); } - onChannelDeleted(event) { + onConversationDeleted(event) { const { id } = event; - this.channels.remove(id); + this.conversations.remove(id); } onUserEvent(event) { @@ -80,8 +80,8 @@ export class State { } onMessageSent(event) { - const { id, at, channel, sender, body } = event; - this.messages.add({ id, at, channel, sender, body }); + const { id, at, conversation, sender, body } = event; + this.messages.add({ id, at, conversation, sender, body }); } onMessageDeleted(event) { diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte index c7e1f22..658d966 100644 --- a/ui/routes/(app)/+layout.svelte +++ b/ui/routes/(app)/+layout.svelte @@ -7,9 +7,8 @@ import TinyGesture from 'tinygesture'; - import * as api from '$lib/apiServer.js'; - import ChannelList from '$lib/components/ChannelList.svelte'; - import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; + import ConversationList from '$lib/components/ConversationList.svelte'; + import CreateConversationForm from '$lib/components/CreateConversationForm.svelte'; let gesture = null; @@ -20,9 +19,9 @@ onDestroy(session.end.bind(session)); let pageContext = getContext('page'); - let channel = $derived(page.params.channel); + let conversationId = $derived(page.params.conversation); - let channels = $derived(session.channels); + let conversations = $derived(session.conversations); function setUpGestures() { if (!browser) { @@ -46,28 +45,35 @@ } }); - const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveChannel'; + // Automatically migrate last-active-channel info now that we call them "conversations." + const STORE_KEY_LAST_ACTIVE = 'pilcrow:lastActiveConversation'; + const STORE_KEY_LAST_ACTIVE_CHANNEL = 'pilcrow:lastActiveChannel'; - function getLastActiveChannel() { - return browser && JSON.parse(localStorage.getItem(STORE_KEY_LAST_ACTIVE)); + function getLastActiveConversation() { + const stored = + localStorage.getItem(STORE_KEY_LAST_ACTIVE) ?? + localStorage.getItem(STORE_KEY_LAST_ACTIVE_CHANNEL); + return JSON.parse(stored); } - function setLastActiveChannel(channelId) { - browser && localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(channelId)); + function setLastActiveConversation(conversationId) { + localStorage.setItem(STORE_KEY_LAST_ACTIVE, JSON.stringify(conversationId)); + // Once we've saved to the new key, we no longer need the old one. Clean it up. + localStorage.removeItem(STORE_KEY_LAST_ACTIVE_CHANNEL); } afterNavigate(() => { - const lastActiveChannel = getLastActiveChannel(); + const conversationId = getLastActiveConversation(); const inRoot = page.url.pathname === '/'; - if (inRoot && lastActiveChannel) { - goto(`/ch/${lastActiveChannel}`); - } else if (channel) { - setLastActiveChannel(channel || null); + if (inRoot && conversationId) { + goto(`/c/${conversationId}`); + } else if (conversationId) { + setLastActiveConversation(conversationId || null); } }); - async function createChannel(name) { - outbox.createChannel(name); + async function createConversation(name) { + outbox.createConversation(name); } function onbeforeunload(event) { @@ -114,9 +120,9 @@ <div id="interface"> <nav id="sidebar" data-expanded={pageContext.showMenu}> - <ChannelList active={channel} {channels} /> - <div class="create-channel"> - <CreateChannelForm {createChannel} /> + <ConversationList active={conversationId} {conversations} /> + <div class="create-conversation"> + <CreateConversationForm {createConversation} /> </div> </nav> <main> diff --git a/ui/routes/(app)/+page.svelte b/ui/routes/(app)/+page.svelte index 007c5c6..1db0eb2 100644 --- a/ui/routes/(app)/+page.svelte +++ b/ui/routes/(app)/+page.svelte @@ -1,3 +1,3 @@ -<div class="no-active-channel"> - <span class="vertical-aligner"> Please select or create a channel. </span> +<div class="no-active-conversation"> + <span class="vertical-aligner">Please select or create a conversation.</span> </div> diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/c/[conversation]/+page.svelte index 87918f7..e6cd845 100644 --- a/ui/routes/(app)/ch/[channel]/+page.svelte +++ b/ui/routes/(app)/c/[conversation]/+page.svelte @@ -8,12 +8,18 @@ const { data } = $props(); const { session, outbox } = data; - let activeChannel; + let activeConversation; - const channelId = $derived(page.params.channel); - const channel = $derived(session.channels.find((channel) => channel.id === channelId)); - const messages = $derived(session.messages.filter((message) => message.channel === channelId)); - const unsent = $derived(outbox.messages.filter((message) => message.channel === channelId)); + const conversationId = $derived(page.params.conversation); + const conversation = $derived( + session.conversations.find((conversation) => conversation.id === conversationId), + ); + const messages = $derived( + session.messages.filter((message) => message.conversation === conversationId), + ); + const unsent = $derived( + outbox.messages.filter((message) => message.conversation === conversationId), + ); const deleted = $derived(outbox.deleted.map((message) => message.messageId)); const unsentSkeletons = $derived( unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser))), @@ -33,12 +39,12 @@ } function getLastVisibleMessage() { - if (activeChannel) { - const childElements = activeChannel.getElementsByClassName('message'); + if (activeConversation) { + const childElements = activeConversation.getElementsByClassName('message'); const lastInView = Array.from(childElements) .reverse() .find((el) => { - return inView(activeChannel, el); + return inView(activeConversation, el); }); return lastInView; } @@ -46,9 +52,9 @@ function setLastRead() { const lastInView = getLastVisibleMessage(); - const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : channel?.at; + const at = !!lastInView ? DateTime.fromISO(lastInView.dataset.at) : conversation?.at; if (!!at) { - session.local.updateLastReadAt(channelId, at); + session.local.updateLastReadAt(conversationId, at); } } @@ -77,7 +83,7 @@ } async function sendMessage(message) { - outbox.postToChannel(channelId, message); + outbox.sendToConversation(conversationId, message); } async function deleteMessage(id) { @@ -87,7 +93,7 @@ <svelte:window onkeydown={handleKeydown} /> -<div class="active-channel" {onscroll} bind:this={activeChannel}> +<div class="active-conversation" {onscroll} bind:this={activeConversation}> {#each messageRuns as { sender, ownMessage, messages }} <MessageRun {sender} diff --git a/ui/styles/active-channel.css b/ui/styles/active-conversation.css index d6a9b42..981862b 100644 --- a/ui/styles/active-channel.css +++ b/ui/styles/active-conversation.css @@ -1,4 +1,4 @@ -.active-channel { +.active-conversation { padding-left: 1rem; padding-right: 1rem; overflow: auto; diff --git a/ui/styles/overscroll.css b/ui/styles/overscroll.css index 8898f9a..a54235c 100644 --- a/ui/styles/overscroll.css +++ b/ui/styles/overscroll.css @@ -1,8 +1,9 @@ -/* This should help minimize swipe-to-go-back behaviour, enabling our -* swipe-to-reveal-channel-menu behaviour. It won't work in all cases; in iOS -* Safari, when swiping from the screen edge, the OS gets th event and -* handles it before the browser does. -*/ +/* + * This should help minimize swipe-to-go-back behaviour, enabling our + * swipe-to-reveal-conversation-menu behaviour. It won't work in all cases; in + * iOS Safari, when swiping from the screen edge, the OS gets the event and + * handles it before the browser does. + */ html, body { overscroll-behavior-x: none; diff --git a/ui/styles/sidebar.css b/ui/styles/sidebar.css index b825545..aa0d53b 100644 --- a/ui/styles/sidebar.css +++ b/ui/styles/sidebar.css @@ -1,4 +1,4 @@ -/* Sidebar and channel selector */ +/* Sidebar and conversation selector */ #sidebar { background-color: var(--colour-navbar-bg); } @@ -38,19 +38,19 @@ color: var(--colour-navbar-hover-text); } -/* create channel form */ -.create-channel { +/* create conversation form */ +.create-conversation { padding-left: 0.5rem; } -.create-channel form { +.create-conversation form { display: flex; flex-direction: row; justify-content: flex-start; align-items: stretch; } -.create-channel input { +.create-conversation input { padding: 0.5rem; border-radius: 0.5rem 0 0 0.5rem; border: 1px solid var(--colour-input-border); @@ -60,7 +60,7 @@ color: var(--colour-input-text); } -.create-channel button { +.create-conversation button { border-radius: 0 0.5rem 0.5rem 0; border: 1px solid var(--colour-input-border); background-color: var(--colour-input-bg); diff --git a/ui/styles/variables.css b/ui/styles/variables.css index 2758aa1..99705f2 100644 --- a/ui/styles/variables.css +++ b/ui/styles/variables.css @@ -40,8 +40,8 @@ --colour-input-border: color-mix(in srgb, var(--colour-input-bg) 50%, black); --colour-input-text: var(--dark-text); - /* Active channel */ - --colour-active-channel-bg: color-mix(in srgb, var(--colour-base) 25%, white); + /* Active conversation */ + --colour-active-conversation-bg: color-mix(in srgb, var(--colour-base) 25%, white); /* MessageRun */ --colour-message-run-self-bg: color-mix(in srgb, var(--colour-base) 30%, white); diff --git a/ui/tests/lib/components/CreateChannelForm.svelte.test.js b/ui/tests/lib/components/CreateChannelForm.svelte.test.js index 197cb6b..8c7b3fb 100644 --- a/ui/tests/lib/components/CreateChannelForm.svelte.test.js +++ b/ui/tests/lib/components/CreateChannelForm.svelte.test.js @@ -1,37 +1,37 @@ import { render, screen } from '@testing-library/svelte'; import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, describe, it, vi } from 'vitest'; -import CreateChannelForm from '$lib/components/CreateChannelForm.svelte'; +import CreateConversationForm from '$lib/components/CreateConversationForm.svelte'; const user = userEvent.setup(); const mocks = vi.hoisted(() => ({ - createChannel: vi.fn(), + createConversation: vi.fn(), })); -describe('CreateChannelForm', async () => { +describe('CreateConversationForm', async () => { beforeEach(async () => { - render(CreateChannelForm, { - createChannel: mocks.createChannel, + render(CreateConversationForm, { + createConversation: mocks.createConversation, }); }); - describe('creates channels', async () => { + describe('creates conversations', async () => { it('with a non-empty name', async () => { const input = screen.getByRole('textbox'); - await user.type(input, 'channel name'); + await user.type(input, 'conversation name'); const create = screen.getByRole('button'); await user.click(create); - expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith('channel name'); + expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith('conversation name'); }); it('with an empty name', async () => { const create = screen.getByRole('button'); await user.click(create); - expect(mocks.createChannel).toHaveBeenCalledExactlyOnceWith(''); + expect(mocks.createConversation).toHaveBeenCalledExactlyOnceWith(''); }); }); }); diff --git a/ui/tests/lib/components/MessageInput.svelte.test.js b/ui/tests/lib/components/MessageInput.svelte.test.js index c32ce11..b459737 100644 --- a/ui/tests/lib/components/MessageInput.svelte.test.js +++ b/ui/tests/lib/components/MessageInput.svelte.test.js @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ sendMessage: vi.fn(), })); -describe('CreateChannelForm', async () => { +describe('MessageInput', async () => { beforeEach(async () => { render(MessageInput, { sendMessage: mocks.sendMessage, |
