From 2b48b551907f4ed27910777cf95916dfa382a259 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 22 May 2026 20:52:01 +0000 Subject: [PATCH] core: deliver member profiles via relay (#6953) --- ...6-04-29-member-profile-sending-channels.md | 11 + ...05-08-public-groups-via-relays-overview.md | 45 +++ plans/2026-05-08-public-groups-via-relays.md | 378 ++++++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Delivery.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 6 +- src/Simplex/Chat/Library/Subscriber.hs | 138 ++++++- src/Simplex/Chat/Messages/Batch.hs | 119 +++++- src/Simplex/Chat/Protocol.hs | 6 +- src/Simplex/Chat/Store/Delivery.hs | 36 +- src/Simplex/Chat/Store/Groups.hs | 22 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../M20260515_delivery_job_senders.hs | 56 +++ .../Store/Postgres/Migrations/chat_schema.sql | 13 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../M20260515_delivery_job_senders.hs | 55 +++ .../SQLite/Migrations/chat_query_plans.txt | 121 +++--- .../Store/SQLite/Migrations/chat_schema.sql | 6 +- src/Simplex/Chat/View.hs | 2 +- tests/Bots/DirectoryTests.hs | 8 +- tests/ChatTests/Groups.hs | 330 +++++++++++++-- tests/PostgresSchemaDump.hs | 4 +- tests/SchemaDump.hs | 6 +- 24 files changed, 1196 insertions(+), 180 deletions(-) create mode 100644 plans/2026-05-08-public-groups-via-relays-overview.md create mode 100644 plans/2026-05-08-public-groups-via-relays.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs diff --git a/plans/2026-04-29-member-profile-sending-channels.md b/plans/2026-04-29-member-profile-sending-channels.md index 2ee36b676e..be7d26ecab 100644 --- a/plans/2026-04-29-member-profile-sending-channels.md +++ b/plans/2026-04-29-member-profile-sending-channels.md @@ -1,5 +1,16 @@ # Plan: Member Profile Sending in Channels +## Implementation note (2026-05-18) + +The shipped implementation is **monotonic and reuses `member_relations_vector`**, not a new `sent_profile_vector` column: + +- The introduction bit lives in `group_members.member_relations_vector` with status `MRIntroduced`. The M20251117 backfill already populates this column for channel rows (relay role is not admin/owner), and `createNewGroupMember` writes `Binary B.empty` for new members. +- Bits flip 0 → 1 when the relay first announces the member to a recipient via prepended `XGrpMemNew` (or via `XGrpMemIntro` in `introduceInChannel`'s join-time direct path). They are **never cleared**. +- Profile updates propagate via the sender's own signed `XInfo`, forwarded unchanged by the relay. The relay updates its DB on receipt; subscribers verify with the key obtained from the earlier `XGrpMemNew`. Section 5 below ("Clear vector on profile update") is superseded by this — no clearing happens. +- The mutually-exclusive two-column delivery-jobs storage (`single_sender_group_member_id` + `sender_group_member_ids`) collapses into a single nullable `sender_group_member_ids BYTEA` column: `[s]` for single-sender jobs, `[s1, s2, ...]` for multi-sender batches, NULL for sender-less jobs (`DJRelayRemoved`). + +The plan body below is preserved for historical context. + ## Context In channels (relayed groups), subscribers don't know profiles of other subscribers. When subscriber A sends a reaction/message that gets forwarded to subscriber B, B creates an "unknown member" record with a synthesized name. This degrades UX — subscribers see "unknown member" instead of real profiles. diff --git a/plans/2026-05-08-public-groups-via-relays-overview.md b/plans/2026-05-08-public-groups-via-relays-overview.md new file mode 100644 index 0000000000..c0114e237d --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays-overview.md @@ -0,0 +1,45 @@ +# Public groups via relays — plan summary + +A third kind of group: relay-mediated like channels, but every member can post like a +secret group. Resolves the scale ceiling of full-mesh groups without the broadcast-only +governance of channels. Two orthogonal axes, already in the model: + +| `useRelays` | `groupType` | Name | +|-------------|--------------|--------------| +| false | (none) | Secret group | +| true | `GTChannel` | Channel | +| true | `GTGroup` | Public group | ← new +| true | `GTUnknown` | refuse | ← older client sees this for `"group"` + +`useRelays` is transport; `groupType` is the governance model (broadcast vs +participatory). The joiner role is a per-group value the owner sets at creation, +carried on the (owner-signed) channel profile, so every relay derives the same role for +the same group — not from a relay-side global config and not from `groupType`. The +blocker is narrow: no path produces `GTGroup` today, and the channel profile carries +no joiner-role field yet. + +## Shape of the work + +Backend: wire/version bump, type helpers, create command, owner-configured joiner-role +field on the channel profile, relay role derivation from that field. Clients (iOS + +Kotlin mirror): model, audit splitting transport-vs-governance call sites, unified +create flow with a Channel/Public-group toggle that picks the joiner-role default, +views, connect-plan messaging. + +## Threat model deltas vs. channels + +**Relay can fabricate content as any member** (broader than channels, where it could +only forge as owners). Same deniability property as channels by design; future fix is +opt-in content signing. + +Everything else in the channel threat model carries over unchanged. Out of scope for +now: member-to-member DMs in relay-mediated groups — deferred, not killed. + +## Sequencing & boundary + +Hard prerequisite: the member-profile dissemination plan +(`2026-04-29-member-profile-sending-channels.md`) lands first. Then backend → iOS → +Kotlin; platforms ship independently; older clients refuse to join. Owner→relay +role/rejection-rule communication and owner-signature verification on the channel +profile by relays are not planned here — both apply to channels equally; neither blocks +Public groups. diff --git a/plans/2026-05-08-public-groups-via-relays.md b/plans/2026-05-08-public-groups-via-relays.md new file mode 100644 index 0000000000..702f06fe7f --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays.md @@ -0,0 +1,378 @@ +# Plan: Public groups via relays + +Date: 2026-05-08 + +## 1. Overview + +Channels (shipped) are relay-mediated groups in which the relay forwards +content from any sender, but subscribers are pinned to `GRObserver` and +cannot post. Public groups are the second value of the same two-axis design: +same wire, same transport, members can post. The blocker is narrow — no +path produces `groupType = GTGroup`, and the relay's joiner-role default +comes from a global config instead of the owner-signed channel profile. +Add a `memberRole` field to the profile, plumb it (with `groupType`) +through the create command, derive the relay's joiner role from it, audit +clients for sites that conflate transport with governance, ride on the +approved member-profile dissemination plan. Member-to-member DMs in +relay-mediated groups are deferred (§10). + +## 2. Concept summary: the `useRelays × groupType` matrix + +| `useRelays` | `groupType` | Name | Wire shape | UX | +|-------------|-------------------------|-------------------|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| `false` | (no `publicGroup`) | **Secret group** | P2P `x.grp.inv` invitations; full mesh between members; JSON array batch. | Today's group: all members can post; profiles known eagerly; admins moderate. | +| `true` | `GTChannel` | **Channel** | Relay-mediated; subscribers join via channel link; binary signed-batch format; profile carries `memberRole` (default `GRObserver` at creation). | Today's channel: only owners post; subscribers anonymous to each other. | +| `true` | `GTGroup` | **Public group** | Same wire as channel; profile carries `memberRole` (default `GRMember` at creation); profile dissemination on demand. | New: every member can post; member-to-member DMs prohibited (deferred); member roster grown lazily via on-demand profile send. | +| `true` | `GTUnknown _` (decode) | (refuse to join) | Channel link from a newer client; older client sees unknown discriminator. | New clients reject with a clear "needs newer version" message; pre-existing channels unaffected. | + +Three axes: **transport** = `useRelays` (topology, batch, signatures, +delivery); **governance model** = `groupType` (profile dissemination, +member affordances; member DMs prohibited in any relay-mediated group); +**joiner role** = `memberRole` on the owner-signed profile, set at +creation, type-keyed default. Today's client sites branch on `useRelays` +as a proxy for `isChannel` — that's the audit work (§4.2, §5.2). + +## 3. Backend changes + +### 3.1 Wire format / protocol + +New optional `memberRole :: Maybe GroupMemberRole` on `PublicGroupProfile` +(owner-signed; relays read from cache). New chat-protocol version +`publicGroupsVersion` signals understanding of `groupType = "group"` and +`memberRole`. Older peers decode unknown `groupType` as `GTUnknown` +(lossless tag preservation already exists) and ignore unknown JSON fields +— §7 covers behavior. Channel-protocol docs gain a paragraph naming +`groupType` the discriminator and `memberRole` the owner-set joiner role. + +### 3.2 Type changes + +`PublicGroupProfile` gains `memberRole :: Maybe GroupMemberRole`. New +single-line helpers in the same module as `useRelays'`: `groupType'` / +`memberRole'` (accessors); `isPublicGroup'` (`useRelays' && groupType' +== Just GTGroup`); `defaultMemberRoleFor` (`GTChannel → GRObserver`, +`GTGroup → GRMember`, `GTUnknown _ → GRObserver` defensive); +`joinerRoleFor` (canonical resolver — `memberRole'` if present, else +`defaultMemberRoleFor groupType'`). `requiresSignature` unchanged for +MVP; opt-in content signing is future-work mitigation per §6. + +### 3.3 API / command changes + +`APINewPublicGroup` / `/public group` gain `groupType` (default channel) +and optional `memberRole` (default `defaultMemberRoleFor groupType`); both +are written onto the constructed profile. The subscriber-side prepare-group +flow reads `memberRole` from the resolved link with the same fallback. The +`channelSubscriberRole` config is removed (no callers after §3.4); tests +that flipped it migrate to Public groups or explicit `memberRole`. + +#### 3.3.1 Default group preferences + +Public-group defaults equal secret-group defaults — the channel override +(`support = OFF`) does not apply, since member-to-moderator escalation is +expected. Parameterize the existing channel-prefs parser by `GroupType` +(Channel keeps its path; Public group and `GTUnknown` use secret-group). +`directMessages` stays ON by inheritance but is **dormant** in any relay- +mediated group (relay doesn't forward `XGrpDirectInv`; clients hide the +toggle); keeping the wire ON lets a future plan re-enable DMs without a +profile-shape change. + +### 3.4 Message processing + +- **Relay joiner-role derivation** (today reads `channelSubscriberRole`): + switch to `joinerRoleFor gInfo`. Eliminates cross-relay disparity. +- **Member-DM defensive refusal** (`xGrpDirectInv`): when `useRelays'`, + emit `messageError` and create no contact. Belt-and-suspenders with the + §4/§5 client suppression; unreachable today (no forwarding, no P2P). +- **Legacy `x.grp.inv`**: existing channel rejection covers Public groups. +- **`unverifiedAllowed`**: unchanged. Tightening becomes possible once + the dissemination plan distributes member keys; existing TODO is + updated to name that precondition. +- **Inherited unchanged** (add a test each): `checkSendAsGroup` + (role-based), receipts cutoff (count-based), introduce-in-channel + + history (`useRelays`-keyed). +- **`memberAdmission` on relay-mediated join**: hardcoded `GAAccepted` + bypasses review/captcha. Generic relay-mediated-groups gap; §8. + +### 3.5 Database migrations + +No schema migration: `groupType` and `memberRole` ride the existing +JSON-serialized profile; absent fields resolve via `defaultMemberRoleFor`. +The dissemination plan's `sent_profile_vector BLOB` migration is a hard +prerequisite owned by that plan. + +### 3.6 Test scenarios + +Add Public-group helpers paralleling the channel helpers, plus: + +1. Member sends content; all members receive it (no "unknown member" lines). +2. Multi-author session: no "unknown member" lines anywhere. +3. Member edit / delete / react forwarded by relay to all members. +4. Member-DM refused on receive: inject `XGrpDirectInv`, expect `messageError`, no contact created; repeat for Channel. +5. Role changes propagate through signed forwarding. +6. Blocked member's subsequent messages not forwarded. +7. Multi-relay delivery with cross-relay deduplication. +8. History on join. +9. `asGroup=true` from a non-owner member rejected with existing error. +10. Receipts disabled above the 20-member limit. +11. Older-client refusal on `groupType = "group"` shows needs-newer-version. +12. Incognito member posting attributes the incognito profile to others. +13. `memberRole` propagates: explicit `GRAuthor` at creation → joiners get `GRAuthor`; resolved link data carries the value. +14. `memberRole` defaults: Channel → `GRObserver`; Public group → `GRMember`. +15. Old-profile fallback: `memberRole = Nothing` → `defaultMemberRoleFor groupType` (`GRObserver` for Channel). + +## 4. iOS changes + +### 4.1 Model + +Add `case group` to `GroupType` (with serializer arms); +`memberRole: GroupMemberRole?` on `PublicGroupProfile`; `isPublicGroup`, +`groupType`, `memberRole` accessors on `GroupProfile`/`GroupInfo`. Client +uses `memberRole` for display only; authoritative resolution stays on +Haskell. + +### 4.2 Audit `useRelays` vs `isChannel` (≈73 sites) + +Per-site rule: **transport** (link/relay management, owner-can't-leave-own- +relay-group, relay-status indicator, incognito flag display, typing-state +gating, member-DM-affordance suppression) → keep `useRelays`. **Governance** +(titles, "subscribers" vs "members" framing, "Channel preferences" labels, +channel-style vs group-style member display) → switch to `isChannel`. +Roughly 70% flip to `isChannel`. Visually compare Public / Channel / Secret +after. + +### 4.3 Create flow + +Unified view with a "Channel / Public group" segmented control above the +display-name field, defaulting to Channel. The toggle drives the screen +title, link-step label, success screen, and two API parameters: `groupType` +and `memberRole` (`.observer` for Channel, `.member` for Public group — +no separate role picker in MVP). Default `groupPreferences` builder is +`groupType`-keyed per §3.3.1. The `directMessages` toggle is hidden in +the create-flow prefs section when `useRelays`. When `groupType = .group`, +render below the title: + +> "In a Public group, every member can post. Messages are delivered through +> relays you choose, which means a malicious relay could change or +> fabricate messages from any member. Pick relays you trust." + +### 4.4 Strings, views, icons, connect-plan + +- **Strings:** ~5–10 keys mirroring channel forms with `_public_group` + suffixes (create/add/leave/delete/link/temporarily-unavailable/no- + relays), plus `create_public_group_threat_model_note`. Reuse + `group_members_*` for "members" framing; channels keep `_subscriber*`. +- **Compose:** existing role-based gates allow members to post; **suppress + the member-tap "send direct message" affordance in any relay-mediated + group** (client side of the DM prohibition; receive gate at §3.4). +- **Views:** `GroupChatInfoView` and the link view branch three ways at + §4.2 sites; the link view takes `groupInfo` and derives variant inside. + `GroupPreferencesView` hides `directMessages` when `useRelays`. +- **Icon:** `chatIconName` gains a Public-group arm with a distinct icon + (different from channel-antenna and secret-group-people — §8). +- **Members view:** show the relay-known roster; header "subscribers" for + channels, "members" for Public groups. No filtered view in MVP. +- **Connect-plan:** wording keyed on resolved `groupType` — "ok to + subscribe via relays" (channel) vs "ok to join via relays" (Public + group). CLI string changes alongside; tests follow. + +## 5. Kotlin changes + +Mirror of §4 across the Compose surface. Subsections parallel §4 and +note divergences only. + +### 5.1 Model + +`GroupType` gains `Group`; `memberRole` / `isPublicGroup` accessors on +`GroupInfo` / `GroupProfile`. + +### 5.2 Audit `useRelays` vs `isChannel` (≈74 sites) + +Same transport-vs-governance rule as §4.2; ~70% flip to `isChannel`. + +### 5.3 Create flow + +Single-view create with Channel / Public-group toggle driving +`groupType`+`memberRole`; threat-model note below the title; +`directMessages` toggle hidden under `useRelays`. + +### 5.4 Strings, views, icons, ConnectPlan + +Strings, views, icons, and ConnectPlan mirror §4.4. **Kotlin-only:** +chat-list filter chips place Public groups in the "groups" bucket +(mental model: "things I can post in"), not "channels". + +## 6. Threat model: changes from channels + +This section assumes the channel threat model +(`docs/protocol/channels-overview.md` §"Threat model"). Public groups +inherit every property listed there. One threat is *broader* (channels +have a narrower form of the same threat). The relay's "can / cannot" +framing matches the existing doc style; the items below are written so +they can be folded directly into a future revision of +`channels-overview.md` once Public groups ship. + +### 6.A.1 A relay can fabricate content as any member + +Content messages (`XMsgNew`, `XMsgUpdate`, `XMsgDel`, `XMsgReact`, +`XFileCancel`) are unsigned in both channels and Public groups +(`Protocol.hs:1221`, `requiresSignature` lists only roster / +administrative events). In channels this gives a compromised relay +the ability to fabricate content attributed to owners — already +documented in `channels-overview.md` §"Threat model" ("Substitute +unsigned content or selectively drop messages for its subscribers"). +In Public groups, the same property has a **broader blast radius**: +the relay can fabricate content attributed to *any* member, not just +to owners. + +This matches the channel deniability property by design (see +`channels-overview.md` §"Signing scope: roster only, content +optional"): unsigned content is precisely what enables cryptographic +deniability — no third party can prove a member authored anything. +The trade-off is that the operator on the delivery path cannot be +prevented from forging in the same channel. + +**A single compromised relay** + +*can:* + +- Fabricate content messages attributed to any member, not just to + owners. Detectable by other members through cross-relay + consistency (same TODO as the channel case: difference detection + not yet implemented). +- Modify the text or content of messages in transit and re-attribute + the modified message to its original author. +- Drop content messages selectively — same property as channels. + +*cannot:* + +- Forge signed administrative events: `XGrpInfo`, `XGrpPrefs`, + `XGrpMemRole`, `XGrpMemRestrict`, `XGrpMemDel`, `XGrpDel`, + `XGrpLeave`, `XInfo` (`Protocol.hs:1221`). Roster manipulation, + profile changes, and member-attributed leave / profile-update + events all require valid signatures. +- Substitute the channel profile or impersonate an owner — the + channel's entity ID and owner authorization chain are validated + by every recipient against the channel link. The new `memberRole` + field is part of the (owner-signed) channel profile, so a + compromised relay also cannot fabricate a different joiner role + than the owner configured. +- Alter authoritative state on owner devices. + +**Mitigation.** No code change for the MVP. The future-work fix is +opt-in content signing per the channel roadmap +(`channels-overview.md` §"Future work" / "Transcript integrity" / +"Opt-in content signing"). When that ships, owners of Public groups +will be able to require all content (member or owner) to carry a +signature; member keys are already disseminated to other members +via the prior plan (`2026-04-29-member-profile-sending-channels.md`), +so verification on the recipient side is not a separate effort. + +In the meantime, the create-flow help text for "Public group" on +both platforms (§4.3, §5.3) carries this trade-off framing: "In a +Public group, the relay forwards messages on behalf of every member. +A compromised relay could change message text or attribute fabricated +messages to any member. Use a secret group if you need non- +repudiable peer-to-peer messaging." This is the same trade-off +channels make for owner posts; making it explicit at create time +lets users choose Public-group-via-relay vs secret-group based on +whether they value scale or content integrity. + +### 6.A.2 What is unchanged from channels + +Every other property of the channel threat model carries over +without change. In particular: + +- A relay cannot impersonate an owner or substitute the channel + profile (signed events, validated entity ID). The configured + `memberRole` is part of the signed profile, so the relay cannot + unilaterally elevate or demote joiners relative to what the owner + specified. +- A relay cannot determine subscriber / member real identity or + network address (inherited from SMP transport). +- All-relays-compromised-and-colluding cannot forge signed events + or alter owner-authoritative state. +- A passive network observer cannot determine which Public group a + member is in, or correlate Public-group activity with other + SimpleX activity. + +Public-group members get the same participant-privacy guarantees as +channel subscribers, and Public-group owners get the same key-loss +risk profile as channel owners (see `channels-overview.md` +§"Compromise of owner keys" and §"Loss of all owner devices"). + +**Out of scope for now: member-to-member DMs in relay-mediated +groups.** In channels, members do not DM each other today. In Public +groups, this plan prohibits the affordance (client-side and +defensively on the receive path) and the relay does not forward +`XGrpDirectInv`. The relay therefore does not see a "member DM +graph" — that threat (which a forwarded-DM design would have +introduced) does not exist under this plan. A future plan can +re-introduce member-to-member DMs and revisit the metadata trade-off +explicitly; the design space is sketched in §10. + +### 6.A.3 Release-notes line + +For the Public-groups release notes, include a one-line summary of +the new property: + +> "In a Public group, the relay you choose could in principle alter +> or fabricate group messages attributed to any member. Pick relays +> you trust, or use a secret group if you need peer-to-peer message +> integrity." + +## 7. Migration / compatibility + +- **Existing channels unaffected.** Pre-upgrade profiles have no + `memberRole`; readers fall back to `defaultMemberRoleFor GTChannel = + GRObserver`. No data migration. +- **Older clients** decode `groupType = "group"` as `GTUnknown` and must + refuse to join with a "needs newer version" alert; they ignore unknown + fields on channel profiles otherwise. +- **Older relays** forward Public-group traffic but resolve joiner role + from their global config — joiners via un-upgraded relays get the legacy + role and cannot post. Mitigation: warn the owner at create time if any + selected relay's chat version is below `publicGroupsVersion`. Soft + warning, not a hard block. + +## 8. Open questions + +1. **Future member-DM design.** (i) Relay-forwarded `XGrpDirectInv` + (simple; leaks DM-graph metadata); (ii) relay-blind rendezvous via + per-member queues on the profile (privacy-preserving; new protocol). + Either re-derives §6. +2. **`memberAdmission` on relay-mediated join.** Hardcoded `GAAccepted` + bypasses review/captcha; generic relay-mediated-groups gap; defer. +3. **Distinct icon for Public groups.** Visually different from channel- + antenna and secret-group-people metaphors. Pending design review. +4. **`channelSubscriberRole` removal.** Verify no out-of-tree consumer + reads it before deleting. +5. **`memberRole` on profile edit.** MVP exposes no UI; Haskell accepts + the edit but role-rebase of existing members is undefined. Deferred. +6. **Roster filter in members view.** Paginate/filter for 100K+ members? + Generic relay-roster question; defer. +7. **Connect-plan wording.** "subscribe / join / connect via relays" — + pick per `groupType`; update tests when CLI string changes. + +## 9. Sequencing + +1. **Prerequisite:** member-profile dissemination plan lands first. +2. **Backend:** types, `memberRole` field, command parameters, profile- + based role derivation, removal of `channelSubscriberRole`, defensive + `XGrpDirectInv` refusal, tests 1–15. +3. **iOS** then **Kotlin** (independent of each other; API defaults are + backward compatible): model, audit, create flow, strings; then views, + icons, ConnectPlan, DM-affordance suppression. +4. **Older-client refusal, version-bump release notes, channel-docs + updates** ship with the backend release. + +## 10. Adjacent work (not planned here) + +- **Owner→relay communication of rejection rules.** Joiner-role side is + fixed here (travels on the signed profile); rejection-rule side + (admission/captcha) is still relay-side config. Future plan: carry it + on the profile too. +- **Owner-signature verification on the channel profile by relays.** + Affects channels equally; does not gate this plan. +- **Member-to-member DMs in relay-mediated groups.** Deferred (§1, §8 Q1). + A future plan must re-derive §6 — relay-forwarded DMs would re-introduce + (sender, target, time) metadata exposure this plan + avoids. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6e459d6484..bdd7fef0b2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -133,6 +133,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders else exposed-modules: Simplex.Chat.Archive @@ -288,6 +289,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index 822ee5efb9..a6ccc74247 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -161,7 +161,7 @@ instance TextEncoding DeliveryTaskStatus where data MessageDeliveryJob = MessageDeliveryJob { jobId :: Int64, jobScope :: DeliveryJobScope, - singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries + senderGMIds :: [GroupMemberId], body :: ByteString, cursorGMId_ :: Maybe GroupMemberId } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index bb31ee26a5..84eece9915 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2958,7 +2958,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) Nothing body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user gInfo = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index c6c3f92752..8c6d2a20cd 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1167,12 +1167,16 @@ memberIntroEvt gInfo reMember = -- This doesn't create introduction records in db, compared to above methods. introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do +introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + withStore' $ \db -> + setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] let introEvts = map (memberIntroEvt gInfo) modMs forM_ (L.nonEmpty introEvts) $ \introEvts' -> sendGroupMemberMessages user gInfo conn introEvts' + withStore' $ \db -> + setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 08ca90f2a6..f90630bc77 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,11 +28,13 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find) +import Data.List (find, foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import qualified Data.IntSet as IS import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import qualified Data.Set as S import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -46,7 +48,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -2946,7 +2948,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do - unless (useRelays' gInfo && isRelay m) $ checkHostRole m memRole + if useRelays' gInfo && isRelay m + then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel" + else checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else do @@ -3608,17 +3612,13 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body - forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed - forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed + forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" lift . void $ getDeliveryJobWorker True deliveryKey - where - singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId - singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) - | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' - | otherwise = Nothing -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> @@ -3628,7 +3628,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + createMsgDeliveryJob db gInfo jobScope [senderGMId] body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -3647,6 +3647,27 @@ getDeliveryJobWorker hasWork deliveryKey = do getAgentWorker "delivery_job" hasWork a deliveryKey ws $ runDeliveryJobWorker a deliveryKey +-- TODO [relays] dissemination here is unsigned (relay-asserted profile). +-- Future: members sign an XMember on channel join, relay stores it per +-- member and forwards the signed XMember via this sidecar — enables +-- subscribers to verify member profiles out-of-band without trusting the relay. + +-- | Encode an XGrpMemNew for first-introduction dissemination as a direct +-- (non-forwarded) batch element. 'Left' when the encoded element wouldn't +-- fit a singleton batch (see 'maxBatchElementSize'). +encodeMemberNew :: VersionRangeChat -> GroupInfo -> GroupMember -> Either ChatError ByteString +encodeMemberNew vr gInfo member = case encodeChatMessage maxBatchElementSize chatMsg of + ECMEncoded bs -> Right bs + ECMLarge -> Left $ ChatError $ CEException $ "large profile element for member " <> show (groupMemberId' member) + where + chatMsg :: ChatMessage 'Json + chatMsg = + ChatMessage + { chatVRange = vr, + msgId = Nothing, + chatMsgEvent = XGrpMemNew (memberInfo gInfo member) Nothing + } + runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryJobWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config @@ -3688,7 +3709,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + singleSenderGMId_ = case senderGMIds of + [s] -> Just s + _ -> Nothing sendBodyToMembers :: CM () sendBodyToMembers -- channel @@ -3696,16 +3720,88 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- there's no member review in channels, so job spec includePending is ignored DJSGroup {} -> do bucketSize <- asks $ deliveryBucketSize . config - sendLoop bucketSize startingCursor + senders <- withStore' $ \db -> + fmap catMaybes . forM senderGMIds $ \sId -> + fmap eitherToMaybe . runExceptT $ do + sender <- getGroupMemberById db vr user sId + vec <- getMemberRelationsVector db sender + pure (sender, vec) + let missingSenders = length senderGMIds - length senders + when (missingSenders > 0) $ + logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" + -- Small profiles ride inline (extBody); the rest spill + -- into standalone batches that ship before the body. + (extBody, inBodySenders, overflowBatches, activeSenders) <- + if null senders + then pure (body, [], [], []) + else do + -- Skip role > GRMember (mirrors xGrpMemNew gate). + -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar. + let (encoderErrs, validLabeled) = + partitionEithers + [ (\bs -> (s, bs)) <$> encodeMemberNew vr gInfo s + | (s, _) <- senders, memberRole' s <= GRMember + ] + (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled + (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled + packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] + allErrs = encoderErrs <> packerErrs + unless (null allErrs) $ do + logInfo $ "delivery job " <> tshow jobId <> ": dropping " <> tshow (length allErrs) <> " oversized profile element(s)" + toView $ CEvtChatErrors allErrs + let active = inBody <> concatMap snd overflowBatches' + pure (extBody', inBody, overflowBatches', active) + -- Per-job constants — independent of the cursor page in sendLoop. + let senderVec = M.fromList [(groupMemberId' s, v) | (s, v) <- senders] + -- Body IDs: 0 = plain body, 1 = extBody, 2.. = overflow batches in order. + overflowWithIds = zip [2 :: Int ..] overflowBatches + sendLoop bucketSize startingCursor senderVec overflowWithIds inBodySenders extBody activeSenders where - sendLoop :: Int -> Maybe GroupMemberId -> CM () - sendLoop bucketSize cursorGMId_ = do + sendLoop :: Int -> Maybe GroupMemberId -> Map GroupMemberId ByteString -> [(Int, (ByteString, [GroupMember]))] -> [GroupMember] -> ByteString -> [GroupMember] -> CM () + sendLoop bucketSize cursorGMId_ senderVec overflowWithIds inBodySenders extBody activeSenders = do mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize unless (null mems) $ do - deliver body mems + let msgReqs = buildMsgReqs mems + unless (null msgReqs) $ void $ withAgent (`sendMessages` msgReqs) + -- Mark only (sender, recipient) pairs where the bit was MRNew — + -- skip recipients already MRIntroduced (steady-case savings). + let readyMems = [m | m <- mems, isJust (readyMemberConn m)] + markFor sender = do + vec <- M.lookup (groupMemberId' sender) senderVec + let ms = [(indexInGroup r, (IDSubjectIntroduced, MRIntroduced)) | r <- readyMems, getRelation (indexInGroup r) vec == MRNew] + if null ms then Nothing else Just (sender, ms) + senderMarks = mapMaybe markFor activeSenders + unless (null senderMarks) $ + withStore' $ \db -> + forM_ senderMarks $ \(sender, ms) -> + setMemberVectorNewRelations db sender ms let cursorGMId' = groupMemberId' $ last mems withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' - unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + unless (length mems < bucketSize) $ + sendLoop bucketSize (Just cursorGMId') senderVec overflowWithIds inBodySenders extBody activeSenders + where + -- First recipient needing body i carries VRValue (Just i); rest use VRRef i. + -- First piece per connection: aConnId; rest: empty (agent convention). + buildMsgReqs :: [GroupMember] -> [MsgReq] + buildMsgReqs mems = reverse . snd $ foldl' addRecipient (IS.empty, []) mems + where + addRecipient acc r = case readyMemberConn r of + Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) + Nothing -> acc + addPiece conn (k, (issued, reqs)) (bid, msgBody) = + let vor + | IS.member bid issued = VRRef bid + | otherwise = VRValue (Just bid) msgBody + issued' = IS.insert bid issued + connId = if k == 0 then aConnId conn else B.empty + in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + recipientBodyPieces r = + [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, extBody) else (0, body)] + where + missing s = case M.lookup (groupMemberId' s) senderVec of + Just vec -> getRelation (indexInGroup r) vec == MRNew + Nothing -> True DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo @@ -3724,8 +3820,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- fully connected group | otherwise = case singleSenderGMId_ of Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" - Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + Just sId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user sId ms <- buildMemberList sender unless (null ms) $ deliver body ms where diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index a9e835a83e..ed65bd4af7 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -13,20 +13,29 @@ module Simplex.Chat.Messages.Batch encodeBinaryBatch, batchMessages, batchDeliveryTasks1, + batchProfilesWithBody, + batchProfiles, + maxBatchElementSize, ) where import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B -import Data.Int (Int64) -import Data.List (foldl') +import Data.Char (ord) +import Data.Function (on) +import Data.Foldable (foldr') +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Ord (Down (..)) +import Data.Word (Word8) import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Protocol -import Simplex.Chat.Types (VersionRangeChat) +import Data.Maybe (isJust) +import Simplex.Chat.Types (GroupMember (..), LocalProfile (..), VersionRangeChat) import Simplex.Messaging.Encoding (Large (..), smpEncode, smpEncodeList) data BatchMode = BMJson | BMBinary @@ -70,29 +79,29 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +-- | Batches delivery tasks into (batch, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where - addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) - addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task - -- too large: skip, record taskId in largeTaskIds - | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) + addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) + addToBatch (msgBodies, accepted, large, len, n) task + -- too large: skip, record in large + | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages - | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) + | otherwise = (msgBodies, accepted, large, len, n) where - MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task + MessageDeliveryTask {fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) - toResult (msgBodies, taskIds, largeTaskIds, _, _) = + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse taskIds, reverse largeTaskIds) + in (encoded, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString @@ -118,3 +127,83 @@ batchLen _ _ 0 = 0 batchLen _ len 1 = len batchLen BMJson len n = len + n + 1 -- (n - 1) commas + 2 brackets batchLen BMBinary len n = len + n * 2 + 2 -- 2-byte length prefix per element + '=' + count + +-- | Largest element that fits a singleton 'encodeBinaryBatch' inside an +-- agent SMP message: '=' + count(1) + Word16 length prefix(2) = 4 bytes +-- of framing on top of the element. +maxBatchElementSize :: Int +maxBatchElementSize = maxEncodedMsgLength - 4 + +-- | Sort key for the profile packers. No-image profiles are processed +-- first so they pack densely; image-bearing profiles take any remaining +-- space or spill to overflow. +hasImage :: GroupMember -> Bool +hasImage GroupMember {memberProfile = LocalProfile {image}} = isJust image + +-- | Greedy-pack profile elements with 'body' (no-image members first) +-- while the result fits 'maxLen'. Returns (extBody, accepted, overflow, +-- large): the senders whose profile is now inline, the labeled elements +-- that did not fit, and the senders whose element doesn't fit even a +-- singleton batch (must be dropped — equivalent to 'batchMessages' +-- 'errLarge'). +-- +-- Precondition on 'body': must be either 'B.empty' or output of +-- 'encodeBinaryBatch' — the function reads byte 1 as the existing +-- element count and drops bytes 0-1 before reassembly. Passing +-- arbitrary bytes produces malformed output. +batchProfilesWithBody :: Int -> ByteString -> [(GroupMember, ByteString)] -> (ByteString, [GroupMember], [(GroupMember, ByteString)], [GroupMember]) +batchProfilesWithBody maxLen body labeled = + let (_, _, acceptedPairs, overflow, large) = + foldl' step initState (sortBy (compare `on` (hasImage . fst)) labeled) + in (buildBody acceptedPairs, map fst acceptedPairs, overflow, large) + where + initEmpty = B.null body + initLen = B.length body + initCount = if initEmpty then 0 else ord (B.index body 1) + -- (predicted total bytes, predicted count, accepted pairs, overflow, large) + initState = (initLen, initCount, [], [], []) + step (totalLen, count, acceptedPairs, overflow, large) (s, e) + | B.length e + 4 > maxLen = (totalLen, count, acceptedPairs, overflow, s : large) + | count >= 255 = full + | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) + | otherwise = full + where + full = (totalLen, count, acceptedPairs, (s, e) : overflow, large) + -- First element on an empty body costs '=' + count(1) + Word16(2) + element; + -- every subsequent element costs just Word16(2) + element. + candidateLen + | initEmpty && null acceptedPairs = 4 + B.length e + | otherwise = totalLen + 2 + B.length e + -- Assemble the final body once: existing tail (sans '=' + count) with + -- the accepted elements (each length-prefixed) inserted in front, and + -- a refreshed count byte. + buildBody [] = body + buildBody acceptedPairs = + let prefixedNew = B.concat [smpEncode (Large e) | (_, e) <- acceptedPairs] + newCount = initCount + length acceptedPairs + tail_ = if initEmpty then B.empty else B.drop 2 body + in B.concat [B.singleton '=', BS.singleton (fromIntegral newCount :: Word8), prefixedNew, tail_] + +-- | Pack labeled profile elements into one or more (batch, senders) +-- pairs, each bounded by 'maxLen', plus a list of senders whose element +-- doesn't fit even a singleton batch (must be dropped — equivalent to +-- 'batchMessages' 'errLarge'). No-image members first (matches +-- 'batchProfilesWithBody'). +batchProfiles :: Int -> [(GroupMember, ByteString)] -> ([(ByteString, [GroupMember])], [GroupMember]) +batchProfiles maxLen = + finish . foldr addToBatch ([], [], [], 0, 0, []) . sortBy (compare `on` (Down . hasImage . fst)) + where + addToBatch :: (GroupMember, ByteString) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) + addToBatch (s, e) acc@(batches, elems, members, len, n, large) + | B.length e + 4 > maxLen = (batches, elems, members, len, n, s : large) + -- batch overhead: '=' + count (2) + 2-byte length prefix per element + | n + 1 <= 255 && len + B.length e + (n + 1) * 2 + 2 <= maxLen = + (batches, e : elems, s : members, len + B.length e, n + 1, large) + -- doesn't fit current — flush and start new with this element alone + | otherwise = + (flush acc, [e], [s], B.length e, 1, large) + flush :: ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> [(ByteString, [GroupMember])] + flush (batches, _, _, _, 0, _) = batches + flush (batches, elems, members, _, _, _) = + (encodeBinaryBatch elems, members) : batches + finish acc@(_, _, _, _, _, large) = (flush acc, large) diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f9c29e3552..902e919a7f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1242,10 +1242,8 @@ requiresSignature = \case XInfo_ -> True _ -> False --- TODO [relays] relay: vectors tracking which members received which other member profiles/keys. --- TODO - don't forward XGrpLeave/XInfo to members who haven't seen sender's profile/key. --- TODO - unverifiedAllowed is a temporary workaround postponing targeted event forwarding. - +-- TODO [relays] can be tightened — sender keys are now disseminated via +-- TODO prepended XGrpMemNew before forwarded XInfo/XGrpLeave reach the recipient. -- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown. -- Owner keys are always known, so subscribers are required to verify from owners. -- Likewise, subscriber keys are always known to owners, so owners are required to verify from subscribers. diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..75345e5e86 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -34,6 +34,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import qualified Data.List.NonEmpty as L import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Delivery import Simplex.Chat.Protocol hiding (Binary) @@ -45,6 +46,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Util (eitherToMaybe, firstRow') +import Text.Read (readMaybe) #if defined(dbPostgres) import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) @@ -245,8 +247,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds body = do currentTs <- getCurrentTime DB.execute db @@ -254,12 +256,17 @@ createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo + -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. + senderColumn :: Maybe Text + senderColumn + | null senderGMIds = Nothing + | otherwise = Just $ T.intercalate "," $ map (T.pack . show) senderGMIds getPendingDeliveryJobScopes :: DB.Connection -> IO [DeliveryWorkerKey] getPendingDeliveryJobScopes db = @@ -272,7 +279,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -302,17 +309,26 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = - case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} - Nothing -> Left $ SEInvalidDeliveryJob jobId' + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow + -- NULL or empty string means []; otherwise the value must parse + -- as a comma-separated decimal Int64 list. An unparseable + -- segment surfaces as job error rather than silent degradation. + senderGMIds <- case senderGMIdsText_ of + Nothing -> Right [] + Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + parseSenderGMIds :: Text -> Maybe [GroupMemberId] + parseSenderGMIds t + | T.null t = Just [] + | otherwise = traverse (readMaybe . T.unpack) (T.splitOn "," t) markJobFailed :: Int64 -> IO () markJobFailed jobId = DB.execute db "UPDATE delivery_jobs SET failed = 1 where delivery_job_id = ?" (Only jobId) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 9b21f0697b..22e9c89b79 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1365,11 +1365,11 @@ createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {grou db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, memProfileId, currentTs, currentTs) ) liftIO $ insertedRowId db @@ -1402,12 +1402,12 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) ) insertedRowId db @@ -1575,12 +1575,12 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus, Binary B.empty) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -2162,7 +2162,7 @@ updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do - v_ <- maybeFirstRow fromOnly $ + v_ <- fmap join . maybeFirstRow fromOnly $ DB.query db ( "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" @@ -2226,7 +2226,7 @@ setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {ind getMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString getMemberRelationsVector db GroupMember {groupMemberId} = - ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + ExceptT . firstRow (fromMaybe B.empty . fromOnly) (SEGroupMemberNotFound groupMemberId) $ DB.query db "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 437f16a43c..ed0b3c9312 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -61,7 +62,8 @@ schemaMigrations = ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), - ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..d082587391 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,56 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260515_delivery_job_senders :: Text +m20260515_delivery_job_senders = + [r| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = single_sender_group_member_id::text +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Text +down_m20260515_delivery_job_senders = + [r| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND position(',' in sender_group_member_ids) = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = sender_group_member_ids::bigint + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN position(',' in sender_group_member_ids) > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE sender_group_member_ids::bigint + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 6026049313..86cce86d9e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -635,14 +635,14 @@ CREATE TABLE test_chat_schema.delivery_jobs ( job_scope_spec_tag text, job_scope_include_pending smallint, job_scope_support_gm_id bigint, - single_sender_group_member_id bigint, body bytea, cursor_group_member_id bigint, job_status text NOT NULL, job_err_reason text, failed smallint DEFAULT 0, created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + updated_at timestamp with time zone DEFAULT now() NOT NULL, + sender_group_member_ids text ); @@ -2203,10 +2203,6 @@ CREATE INDEX idx_delivery_jobs_next ON test_chat_schema.delivery_jobs USING btre -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON test_chat_schema.delivery_jobs USING btree (single_sender_group_member_id); - - - CREATE INDEX idx_delivery_tasks_created_at ON test_chat_schema.delivery_tasks USING btree (created_at); @@ -2835,11 +2831,6 @@ ALTER TABLE ONLY test_chat_schema.delivery_jobs -ALTER TABLE ONLY test_chat_schema.delivery_jobs - ADD CONSTRAINT delivery_jobs_single_sender_group_member_id_fkey FOREIGN KEY (single_sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.delivery_tasks ADD CONSTRAINT delivery_tasks_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 9990ed74fd..65ccb8e58f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -154,6 +154,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -307,7 +308,8 @@ schemaMigrations = ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), - ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index) + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..67a9ae31e8 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260515_delivery_job_senders :: Query +m20260515_delivery_job_senders = + [sql| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = CAST(single_sender_group_member_id AS TEXT) +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Query +down_m20260515_delivery_job_senders = + [sql| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND instr(sender_group_member_ids, ',') = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = CAST(sender_group_member_ids AS INTEGER) + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN instr(sender_group_member_ids, ',') > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE CAST(sender_group_member_ids AS INTEGER) + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 127fce8e45..dbbe2f8a0a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -31,7 +31,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -84,7 +83,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -285,7 +283,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -320,7 +317,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -355,7 +351,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -522,49 +517,13 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link - ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -598,7 +557,40 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -633,7 +625,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -685,7 +676,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -1156,13 +1147,12 @@ SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_re Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1198,7 +1188,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1829,7 +1818,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1864,7 +1852,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -3889,6 +3876,15 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + +Plan: +SCAN s +SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) + Query: SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled FROM protocol_servers @@ -3969,6 +3965,15 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=?) +Query: + UPDATE chat_items + SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 + RETURNING chat_item_id + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) + Query: UPDATE chat_items SET item_deleted = ?, item_deleted_ts = ?, item_deleted_by_group_member_id = ?, updated_at = ? @@ -4681,7 +4686,7 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: @@ -6294,7 +6299,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6324,7 +6328,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6870,6 +6873,10 @@ Query: SELECT member_status FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_status FROM group_members WHERE member_role = 'relay' +Plan: +SCAN group_members + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6990,6 +6997,10 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + Query: UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 86c198670c..8fca9f2e84 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -734,7 +734,6 @@ CREATE TABLE delivery_jobs( job_scope_spec_tag TEXT, job_scope_include_pending INTEGER, job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, - single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, body BLOB, cursor_group_member_id INTEGER, job_status TEXT NOT NULL, @@ -742,6 +741,8 @@ CREATE TABLE delivery_jobs( failed INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + sender_group_member_ids TEXT ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, @@ -1218,9 +1219,6 @@ CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs( job_scope_support_gm_id ); -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs( - single_sender_group_member_id -); CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( group_id, worker_scope, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 477850d4b0..7785d06d44 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1326,7 +1326,7 @@ viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupM [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") ] - _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group"] + _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " introduced " <> ttyFullMember m <> " in the channel"] | otherwise -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 7fdd34061f..0abbe5bd65 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1996,7 +1996,7 @@ testRegisterChannelViaCard ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] -- owner sends a message to trigger member introduction bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." @@ -2095,7 +2095,7 @@ testDeleteChannelRegistration ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2139,7 +2139,7 @@ testReregistrationAlreadyListed ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2198,7 +2198,7 @@ testLinkCheckUpdatesCount ps = do [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index e0ff178db4..82bf20e6cf 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,8 +18,9 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) -import Data.Maybe (fromMaybe, maybeToList) +import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Maybe (fromMaybe, listToMaybe, maybeToList) import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M @@ -32,7 +33,7 @@ import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types -import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -251,6 +252,13 @@ chatGroupTests = do describe "multiple relays" $ do it "2 relays: should deliver messages to members" testChannels2RelaysDeliver it "should share same incognito profile with all relays" testChannels2RelaysIncognito + describe "deliver member profiles via relay" $ do + it "late joiner (no prior history) learns sender on first forward" testChannelLateJoinerReceivesProfile + it "2 relays: deduplicate member announcement" testChannel2RelaysDeduplicateProfile + it "multi senders disseminate independently" testChannelMultiSendersIndependent + it "large profile fits in body" testChannelLargeProfileFits + it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate @@ -8546,10 +8554,11 @@ testChannels1RelayDeliver ps = -- alice knows cath via XGrpMemNew announcement from relay alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + -- dan/eve learn cath via prepended XGrpMemNew before the forwarded reaction + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" @@ -8689,7 +8698,7 @@ memberJoinChannel' gName gId relaySfx ownerSfx memberRelaySfx relays owners shor relay <## ("#" <> gName <> ": " <> sfxMName relaySfx <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> sfxName ownerSfx <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> sfxName ownerSfx <> " in the channel")] | owner <- owners ] @@ -8721,11 +8730,29 @@ memberJoinChannelIncognito gName relays owners shortLink fullLink member = do relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> memIncognito <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> memIncognito <> " in the channel")] | owner <- owners ] pure memIncognito +-- | Assert that sender's member_relations_vector has 'MRIntroduced' at +-- the recipient's index, looked up by display name on the same DB. +memberIntroducedTo :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () +memberIntroducedTo cc senderName recipientName = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + [sql| + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + |] + (senderName, recipientName) :: + IO [(Maybe ByteString, Int64)] + case rows of + [(mv, idx)] -> getRelation idx (fromMaybe B.empty mv) `shouldBe` MRIntroduced + _ -> expectationFailure $ "memberIntroducedTo: expected exactly one row for " <> show (senderName, recipientName) <> ", got " <> show (length rows) + testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () testChannels1RelayDeliverLoop deliveryBucketSize ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8745,10 +8772,10 @@ testChannels1RelayDeliverLoop deliveryBucketSize ps = bob <## " + 👍" alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" where @@ -8787,14 +8814,14 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + <### [ "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8802,8 +8829,8 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", - "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced cath (Catherine) in the channel", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8814,6 +8841,231 @@ testChannelsSenderDeduplicateOwn ps = do where cfg = testCfg {deliveryWorkerDelay = 250000} +testChannelLateJoinerReceivesProfile :: HasCallStack => TestParams -> IO () +testChannelLateJoinerReceivesProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- first forward: dan learns cath via prepended XGrpMemNew. + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + -- second forward: dan's bit is set, no prepend, no view event. + cath #> "#team hi again" + bob <# "#team cath> hi again" + alice <# "#team cath> hi again [>>]" + dan <# "#team cath> hi again [>>]" + + memberIntroducedTo bob "cath" "alice" + memberIntroducedTo bob "cath" "dan" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team renamed" + bob <# "#team kate> renamed" + alice <# "#team kate> renamed [>>]" + dan <# "#team kate> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "alice" + memberIntroducedTo bob "kate" "dan" + +testChannel2RelaysDeduplicateProfile :: HasCallStack => TestParams -> IO () +testChannel2RelaysDeduplicateProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + (shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + + -- first forward: both relays prepend XGrpMemNew(dan) for eve; + -- second hits xGrpMemNew's "already created via another relay" branch. + dan #> "#team hi" + bob <# "#team dan> hi" + cath <# "#team dan> hi" + alice <# "#team dan> hi [>>]" + eve .<## " introduced dan (Daniel) in the channel" + eve <# "#team dan> hi [>>]" + + -- second forward: eve's bit is set on both relays, no prepend. + dan #> "#team hi again" + bob <# "#team dan> hi again" + cath <# "#team dan> hi again" + alice <# "#team dan> hi again [>>]" + eve <# "#team dan> hi again [>>]" + + -- both relays independently mark eve in dan's vector; + -- alice's bit was set at join via introduceInChannel and stays set. + memberIntroducedTo bob "dan" "alice" + memberIntroducedTo bob "dan" "eve" + memberIntroducedTo cath "dan" "alice" + memberIntroducedTo cath "dan" "eve" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + dan ##> "/p dean Dean" + dan <## "user profile is changed to dean (Dean) (your 0 contacts are notified)" + + dan #> "#team renamed" + bob <# "#team dean> renamed" + cath <# "#team dean> renamed" + alice <# "#team dean> renamed [>>]" + eve <# "#team dean> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "dean" "alice" + memberIntroducedTo bob "dean" "eve" + memberIntroducedTo cath "dean" "alice" + memberIntroducedTo cath "dean" "eve" + +testChannelLargeProfileFits :: HasCallStack => TestParams -> IO () +testChannelLargeProfileFits ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- ~14000 chars: profile fits in a singleton batch AND packs + -- inline with the forwarded body (exercises the in-body path). + let bigImage = T.pack ("data:image/png;base64," <> replicate 14000 'A') + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + +testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () +testChannelMultipleLargeProfiles ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- ~14500 chars each: one rides inline with the body, + -- the other spills into a standalone overflow batch. + let cathImage = T.pack ("data:image/png;base64," <> replicate 14500 'A') + danImage = T.pack ("data:image/png;base64," <> replicate 14500 'B') + withCCTransaction bob $ \db -> do + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (cathImage, "cath" :: T.Text) + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (danImage, "dan" :: T.Text) + + -- deliveryWorkerDelay=250ms lets the relay coalesce cath's and + -- dan's sends into one multi-sender job. + cath #> "#team from cath" + bob <# "#team cath> from cath" + dan #> "#team from dan" + bob <# "#team dan> from dan" + + alice + <### [ WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + cath + <### [ "#team: bob introduced dan (Daniel) in the channel", + WithTime "#team dan> from dan [>>]" + ] + dan + <### [ "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]" + ] + eve + <### [ "#team: bob introduced dan (Daniel) in the channel", + "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + + memberIntroducedTo bob "cath" "eve" + memberIntroducedTo bob "dan" "eve" + where + cfg = testCfg {deliveryWorkerDelay = 250000} + +-- Asserted via SQL on the relay's DB rather than terminal output: the +-- "updated profile" chat item rendering on relays/owners is order-sensitive. +testChannelProfileUpdateNoRePrepend :: HasCallStack => TestParams -> IO () +testChannelProfileUpdateNoRePrepend ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + + -- /p only delivers XInfo to direct contacts; for group members it + -- piggybacks on the next group send via shouldSendProfileUpdate. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team hi again" + bob <# "#team kate> hi again" + alice <# "#team kate> hi again [>>]" + dan <# "#team kate> hi again [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "dan" + +testChannelMultiSendersIndependent :: HasCallStack => TestParams -> IO () +testChannelMultiSendersIndependent ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- cath posts: dan and eve learn cath via prepended XGrpMemNew + cath #> "#team from cath" + bob <# "#team cath> from cath" + alice <# "#team cath> from cath [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> from cath [>>]" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> from cath [>>]" + + -- dan posts: cath and eve learn dan independently of cath's vector + dan #> "#team from dan" + bob <# "#team dan> from dan" + alice <# "#team dan> from dan [>>]" + cath <## "#team: bob introduced dan (Daniel) in the channel" + cath <# "#team dan> from dan [>>]" + eve <## "#team: bob introduced dan (Daniel) in the channel" + eve <# "#team dan> from dan [>>]" + + -- second post from cath: all recipients have cath marked, no prepend + cath #> "#team again from cath" + bob <# "#team cath> again from cath" + alice <# "#team cath> again from cath [>>]" + dan <# "#team cath> again from cath [>>]" + eve <# "#team cath> again from cath [>>]" + testChannels2RelaysDeliver :: HasCallStack => TestParams -> IO () testChannels2RelaysDeliver ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8836,10 +9088,10 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" - eve .<## " forwarded a message from an unknown member, creating unknown member record dan" + eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" - frank .<## " forwarded a message from an unknown member, creating unknown member record dan" + frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" @@ -8874,10 +9126,10 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" - eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" - frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" @@ -9090,10 +9342,10 @@ testChannelChangeRoleSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9145,10 +9397,10 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9208,10 +9460,10 @@ testChannelRemoveMemberSigned ps = concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9376,10 +9628,10 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9430,7 +9682,9 @@ testChannelSubscriberLeave ps = dan <## "use /d #team to delete the group" bob <## "#team: dan left the group (signed)" alice <## "#team: dan left the group (signed)" - -- eve doesn't know dan - no unknown member record created (skipped for XGrpLeave) + -- dan never sent before leaving, so dan's profile is disseminated to eve + -- via prepended XGrpMemNew before the forwarded XGrpLeave + eve <## "#team: bob introduced dan (Daniel) in the channel" alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) @@ -9449,8 +9703,10 @@ testChannelSubscriberLeave ps = checkMemberStatus alice "dan" (Just "left") checkMemberStatus bob "dan" (Just "left") checkMemberStatus dan "dan" (Just "left") - -- eve doesn't know dan - no member record (XGrpLeave skips unknown member creation) - checkMemberStatus eve "dan" Nothing + -- eve learned dan via prepended XGrpMemNew before the forwarded XGrpLeave, + -- so eve now has a record for dan with status "left" + checkMemberStatus eve "dan" (Just "left") + -- cath left earlier and was excluded from the forward; no record on cath checkMemberStatus cath "dan" Nothing where checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () @@ -9627,10 +9883,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9673,10 +9929,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] -- no profile update items in main scope (dan has no support chat, item not created) @@ -10441,11 +10697,11 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" ] @@ -10801,9 +11057,9 @@ testChannelMemberMessageUpdate ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -10832,9 +11088,9 @@ testChannelMemberMessageDelete ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 7df0beb2fa..197e9a9b89 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -78,5 +78,7 @@ skipComparisonForDownMigrations = [ -- via_group field moves "20250922_remove_unused_connections", -- group_member_intro_id field moves - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column is re-added at the end of the table + "20260515_delivery_job_senders" ] diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 2c4ba05ce6..bc74f3ec33 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -141,7 +141,11 @@ skipComparisonForDownMigrations = -- indexes move down to the end of the file "20250922_remove_unused_connections", -- group_member_intros table moves down to the end of the file - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column and its index + -- are re-added at the end of the table / file (ALTER TABLE ADD COLUMN + -- appends; CREATE INDEX appends). + "20260515_delivery_job_senders" ] getSchema :: FilePath -> FilePath -> IO String