max / makenotwork
8 files changed,
+312 insertions,
-16 deletions
| @@ -10759,6 +10759,22 @@ | |||
| 10759 | 10759 | "pkg-config", | |
| 10760 | 10760 | ] | |
| 10761 | 10761 | ||
| 10762 | + | [[patch.unused]] | |
| 10763 | + | name = "quasi-immediate" | |
| 10764 | + | version = "0.91.1" | |
| 10765 | + | ||
| 10766 | + | [[patch.unused]] | |
| 10767 | + | name = "quasi-notifs" | |
| 10768 | + | version = "0.91.1" | |
| 10769 | + | ||
| 10770 | + | [[patch.unused]] | |
| 10771 | + | name = "quasi-store" | |
| 10772 | + | version = "0.1.0" | |
| 10773 | + | ||
| 10774 | + | [[patch.unused]] | |
| 10775 | + | name = "quasi-tauri" | |
| 10776 | + | version = "0.91.1" | |
| 10777 | + | ||
| 10762 | 10778 | [[patch.unused]] | |
| 10763 | 10779 | name = "kberg" | |
| 10764 | 10780 | version = "0.1.0" | |
| @@ -10778,19 +10794,3 @@ | |||
| 10778 | 10794 | [[patch.unused]] | |
| 10779 | 10795 | name = "synckit-config" | |
| 10780 | 10796 | version = "0.2.0" | |
| 10781 | - | ||
| 10782 | - | [[patch.unused]] | |
| 10783 | - | name = "quasi-immediate" | |
| 10784 | - | version = "0.91.1" | |
| 10785 | - | ||
| 10786 | - | [[patch.unused]] | |
| 10787 | - | name = "quasi-notifs" | |
| 10788 | - | version = "0.91.1" | |
| 10789 | - | ||
| 10790 | - | [[patch.unused]] | |
| 10791 | - | name = "quasi-store" | |
| 10792 | - | version = "0.1.0" | |
| 10793 | - | ||
| 10794 | - | [[patch.unused]] | |
| 10795 | - | name = "quasi-tauri" | |
| 10796 | - | version = "0.91.1" |
| @@ -243,6 +243,27 @@ | |||
| 243 | 243 | /// than materializing every follower in one query, and so the per-row email | |
| 244 | 244 | /// `EXISTS` reveal runs only for a bounded page at a time (ultra-fuzz Run 4 S1). | |
| 245 | 245 | /// Stable `(created_at, follower_id)` ordering keeps OFFSET batches consistent. | |
| 246 | + | /// How many rows the follower export will page through. | |
| 247 | + | /// | |
| 248 | + | /// The same set `get_followers_for_export_page` walks, counted rather than | |
| 249 | + | /// paged. Asked once before the export starts, so `follower_exports` can record | |
| 250 | + | /// what was about to be handed over (`159a7a20`). | |
| 251 | + | #[tracing::instrument(skip_all)] | |
| 252 | + | pub(crate) async fn count_followers_for_export(pool: &PgPool, user_id: UserId) -> Result<i64> { | |
| 253 | + | let count = sqlx::query_scalar::<_, i64>( | |
| 254 | + | r" | |
| 255 | + | SELECT COUNT(*) | |
| 256 | + | FROM follows f | |
| 257 | + | WHERE (f.target_type = 'user' AND f.target_id = $1) | |
| 258 | + | OR (f.target_type = 'project' AND f.target_id IN (SELECT id FROM projects WHERE user_id = $1)) | |
| 259 | + | ", | |
| 260 | + | ) | |
| 261 | + | .bind(user_id) | |
| 262 | + | .fetch_one(pool) | |
| 263 | + | .await?; | |
| 264 | + | Ok(count) | |
| 265 | + | } | |
| 266 | + | ||
| 246 | 267 | pub(crate) async fn get_followers_for_export_page( | |
| 247 | 268 | pool: &PgPool, | |
| 248 | 269 | user_id: UserId, |
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | pub(crate) mod email_suppressions; | |
| 26 | 26 | mod enums; | |
| 27 | 27 | pub(crate) mod fan_plus; | |
| 28 | + | pub mod follower_exports; // pub so the integration test crate can drive the erasure query directly | |
| 28 | 29 | pub(crate) mod follows; | |
| 29 | 30 | pub mod gallery_images; | |
| 30 | 31 | pub mod git_access_tokens; |
| @@ -709,6 +709,29 @@ | |||
| 709 | 709 | /// One page of a creator's project subscribers for CSV export, newest first. | |
| 710 | 710 | /// Paginated for bounded-memory streaming (ultra-fuzz Run 4 S1); stable | |
| 711 | 711 | /// `(created_at, id)` ordering keeps OFFSET batches consistent. | |
| 712 | + | /// How many rows the subscriber half of the follower export will page through. | |
| 713 | + | /// | |
| 714 | + | /// The same set `get_project_subscribers_for_export_page` walks. Asked once | |
| 715 | + | /// before the export starts, so `follower_exports` can record what was about to | |
| 716 | + | /// be handed over (`159a7a20`). | |
| 717 | + | #[tracing::instrument(skip_all)] | |
| 718 | + | pub(crate) async fn count_project_subscribers_for_export( | |
| 719 | + | pool: &PgPool, | |
| 720 | + | user_id: UserId, | |
| 721 | + | ) -> Result<i64> { | |
| 722 | + | let count = sqlx::query_scalar::<_, i64>( | |
| 723 | + | r" | |
| 724 | + | SELECT COUNT(*) | |
| 725 | + | FROM subscriptions s | |
| 726 | + | WHERE s.project_id IN (SELECT id FROM projects WHERE user_id = $1) | |
| 727 | + | ", | |
| 728 | + | ) | |
| 729 | + | .bind(user_id) | |
| 730 | + | .fetch_one(pool) | |
| 731 | + | .await?; | |
| 732 | + | Ok(count) | |
| 733 | + | } | |
| 734 | + | ||
| 712 | 735 | pub(crate) async fn get_project_subscribers_for_export_page( | |
| 713 | 736 | pool: &PgPool, | |
| 714 | 737 | user_id: UserId, |
| @@ -554,3 +554,69 @@ | |||
| 554 | 554 | "a legacy NULL-size file must still be exported (size resolved via S3 HEAD)" | |
| 555 | 555 | ); | |
| 556 | 556 | } | |
| 557 | + | ||
| 558 | + | /// `159a7a20`. The promise in `mailing-list-data-processing.md` -- that an | |
| 559 | + | /// erasure reaches an exported copy -- rested on somebody remembering who had | |
| 560 | + | /// exported. This is the row that makes it a query. | |
| 561 | + | #[tokio::test] | |
| 562 | + | async fn a_follower_export_leaves_a_row_an_erasure_can_find() { | |
| 563 | + | let mut h = TestHarness::new().await; | |
| 564 | + | let seller_id = h | |
| 565 | + | .create_creator_with_item("erasureseller", "digital", 1000) | |
| 566 | + | .await | |
| 567 | + | .user_id; | |
| 568 | + | ||
| 569 | + | let follower_id = create_buyer(&h.db).await; | |
| 570 | + | let seller_uuid: uuid::Uuid = seller_id.into(); | |
| 571 | + | insert_follow(&h.db, follower_id, seller_uuid).await; | |
| 572 | + | ||
| 573 | + | let resp = h.client.post_form("/api/export/followers", "").await; | |
| 574 | + | assert_eq!(resp.status, 200, "export failed: {}", resp.text); | |
| 575 | + | ||
| 576 | + | // The follower this creator was about to be handed. | |
| 577 | + | let counted = sqlx::query_as::<_, (Option<i64>, Option<i64>)>( | |
| 578 | + | "SELECT follower_count, subscriber_count FROM follower_exports WHERE user_id = $1", | |
| 579 | + | ) | |
| 580 | + | .bind(seller_id) | |
| 581 | + | .fetch_one(&h.db) | |
| 582 | + | .await | |
| 583 | + | .expect("the export left a row"); | |
| 584 | + | assert_eq!(counted.0, Some(1), "one follower was about to be exported"); | |
| 585 | + | assert_eq!(counted.1, Some(0)); | |
| 586 | + | ||
| 587 | + | // And the erasure query names that creator without reading an address out | |
| 588 | + | // of the export: it intersects the relationship with the export's time. | |
| 589 | + | let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id) | |
| 590 | + | .await | |
| 591 | + | .expect("the erasure query runs"); | |
| 592 | + | assert_eq!(contact.len(), 1, "one creator to write to"); | |
| 593 | + | assert_eq!(contact[0].creator_id, seller_id); | |
| 594 | + | assert_eq!(contact[0].exports, 1); | |
| 595 | + | } | |
| 596 | + | ||
| 597 | + | /// An export that ran before the follow could not have carried this person, so | |
| 598 | + | /// it is not a creator to write to. | |
| 599 | + | #[tokio::test] | |
| 600 | + | async fn an_export_that_predates_the_follow_is_not_a_creator_to_contact() { | |
| 601 | + | let mut h = TestHarness::new().await; | |
| 602 | + | let seller_id = h | |
| 603 | + | .create_creator_with_item("earlyseller", "digital", 1000) | |
| 604 | + | .await | |
| 605 | + | .user_id; | |
| 606 | + | ||
| 607 | + | // The export happens first, with nobody following. | |
| 608 | + | let resp = h.client.post_form("/api/export/followers", "").await; | |
| 609 | + | assert_eq!(resp.status, 200, "export failed: {}", resp.text); | |
| 610 | + | ||
| 611 | + | let follower_id = create_buyer(&h.db).await; | |
| 612 | + | let seller_uuid: uuid::Uuid = seller_id.into(); | |
| 613 | + | insert_follow(&h.db, follower_id, seller_uuid).await; | |
| 614 | + | ||
| 615 | + | let contact = makenotwork::db::follower_exports::creators_to_contact(&h.db, follower_id) | |
| 616 | + | .await | |
| 617 | + | .expect("the erasure query runs"); | |
| 618 | + | assert!( | |
| 619 | + | contact.is_empty(), | |
| 620 | + | "an export before the follow carried nobody: {contact:?}" | |
| 621 | + | ); | |
| 622 | + | } |
| @@ -723,6 +723,30 @@ | |||
| 723 | 723 | let pool = db.clone(); | |
| 724 | 724 | let uid = user.id; | |
| 725 | 725 | ||
| 726 | + | // Noted before a byte leaves, because the promise in | |
| 727 | + | // `site-docs/public/legal/mailing-list-data-processing.md` -- that an | |
| 728 | + | // erasure reaches an exported copy -- rested on somebody remembering who | |
| 729 | + | // had exported (`159a7a20`). | |
| 730 | + | // | |
| 731 | + | // The request rather than the completion: this streams from a spawned task | |
| 732 | + | // below, so a run can end partway, and a partial file still means addresses | |
| 733 | + | // left the building. Recording the completion would miss exactly the runs | |
| 734 | + | // most likely to have gone wrong. | |
| 735 | + | // | |
| 736 | + | // A count that cannot be read does not refuse the export. The creator is | |
| 737 | + | // entitled to their own data, and a privacy record that can block a lawful | |
| 738 | + | // request is the worse failure -- so the row is written with the number | |
| 739 | + | // missing, which says "this happened and we could not say how big" rather | |
| 740 | + | // than claiming zero. | |
| 741 | + | let followers = db::follows::count_followers_for_export(&db, uid).await.ok(); | |
| 742 | + | let subscribers = db::subscriptions::count_project_subscribers_for_export(&db, uid) | |
| 743 | + | .await | |
| 744 | + | .ok(); | |
| 745 | + | if let Err(error) = db::follower_exports::record(&db, uid, followers, subscribers).await { | |
| 746 | + | tracing::error!(error = ?error, user_id = %uid, | |
| 747 | + | "follower export not recorded; an erasure will not see this one"); | |
| 748 | + | } | |
| 749 | + | ||
| 726 | 750 | // Two-section CSV (followers, then subscribers); each section pages | |
| 727 | 751 | // independently so the whole thing streams in bounded batches (Run 4 S1). | |
| 728 | 752 | let (tx, rx) = mpsc::channel::<Bytes>(4); |
| @@ -1,0 +1,45 @@ | |||
| 1 | + | -- Who exported their followers, and when. | |
| 2 | + | -- | |
| 3 | + | -- `POST /api/export/followers` streams a creator's whole follower and | |
| 4 | + | -- project-subscriber set, addresses included, and recorded nothing. | |
| 5 | + | -- `site-docs/public/legal/mailing-list-data-processing.md` promises a | |
| 6 | + | -- subscriber that an erasure reaches an exported copy, and that promise rested | |
| 7 | + | -- on a person remembering who had exported. This is the row that makes it a | |
| 8 | + | -- query (`159a7a20`, option b, 2026-08-30). | |
| 9 | + | -- | |
| 10 | + | -- No addresses and no list id. The endpoint is `AuthUser`-scoped and exports | |
| 11 | + | -- everything the creator has, so there is no list to name -- and holding a | |
| 12 | + | -- second copy of anyone's address to answer a question about copies of | |
| 13 | + | -- addresses is the thing this is meant to avoid. | |
| 14 | + | -- | |
| 15 | + | -- The erasure query is: the creators this subscriber follows or subscribes to, | |
| 16 | + | -- intersected with the creators who exported after that relationship began. | |
| 17 | + | -- | |
| 18 | + | -- ACCEPTED BLIND SPOT, decided rather than discovered: `db::follows` hard-deletes | |
| 19 | + | -- on unfollow, so a subscriber who has already unfollowed leaves no relationship | |
| 20 | + | -- row to intersect against and this cannot see them. Answering that needs a | |
| 21 | + | -- stored set of exported addresses, hashed or otherwise, and that second copy | |
| 22 | + | -- was declined. Do not quietly add one; reopen the decision if the case turns | |
| 23 | + | -- up in practice. | |
| 24 | + | CREATE TABLE IF NOT EXISTS follower_exports ( | |
| 25 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 26 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 27 | + | -- What the export was about to hand over. Counted at request time, because | |
| 28 | + | -- the export streams from a spawned task and a run that ends partway still | |
| 29 | + | -- means addresses left the building -- which is the only thing the erasure | |
| 30 | + | -- follow-up cares about. | |
| 31 | + | -- | |
| 32 | + | -- Nullable, and NULL means "this happened and we could not say how big". | |
| 33 | + | -- A count that fails to read must not refuse the export -- the creator is | |
| 34 | + | -- entitled to their own data, and a privacy record that can block a lawful | |
| 35 | + | -- request is the worse failure -- so the row is written either way, and a | |
| 36 | + | -- missing number says so rather than claiming zero. | |
| 37 | + | follower_count BIGINT CHECK (follower_count IS NULL OR follower_count >= 0), | |
| 38 | + | subscriber_count BIGINT CHECK (subscriber_count IS NULL OR subscriber_count >= 0), | |
| 39 | + | requested_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 40 | + | ); | |
| 41 | + | ||
| 42 | + | -- The erasure query asks "which of these creators exported since a given | |
| 43 | + | -- moment", so the moment is the second column. | |
| 44 | + | CREATE INDEX IF NOT EXISTS idx_follower_exports_user | |
| 45 | + | ON follower_exports (user_id, requested_at DESC); |
| @@ -1,0 +1,116 @@ | |||
| 1 | + | //! Who exported their followers, and when. | |
| 2 | + | //! | |
| 3 | + | //! `159a7a20`, option b, decided 2026-08-30. `POST /api/export/followers` | |
| 4 | + | //! streams a creator's whole follower and project-subscriber set, addresses | |
| 5 | + | //! included, and recorded nothing. `site-docs/public/legal/mailing-list-data-processing.md` | |
| 6 | + | //! promises a subscriber that an erasure reaches an exported copy, and that | |
| 7 | + | //! promise rested on a person remembering who had exported. | |
| 8 | + | //! | |
| 9 | + | //! # No addresses, and no list | |
| 10 | + | //! | |
| 11 | + | //! The endpoint is `AuthUser`-scoped and exports everything the creator has, so | |
| 12 | + | //! there is no list to name. And holding a second copy of anyone's address to | |
| 13 | + | //! answer a question *about* copies of addresses is the thing this exists to | |
| 14 | + | //! avoid: [`creators_to_contact`] intersects relationships the database already | |
| 15 | + | //! has against export timestamps, and reads no address at all. | |
| 16 | + | ||
| 17 | + | use chrono::{DateTime, Utc}; | |
| 18 | + | use sqlx::PgPool; | |
| 19 | + | ||
| 20 | + | use super::id_types::UserId; | |
| 21 | + | use crate::error::Result; | |
| 22 | + | ||
| 23 | + | /// Note that a creator asked for their followers. | |
| 24 | + | /// | |
| 25 | + | /// The request and not the completion. The export streams from a spawned task, | |
| 26 | + | /// so a run can end partway -- and a logged request that produced a partial | |
| 27 | + | /// file still means addresses left the building, which is the only thing the | |
| 28 | + | /// erasure follow-up cares about. Recording the completion instead would miss | |
| 29 | + | /// exactly the runs most likely to have gone wrong. | |
| 30 | + | #[tracing::instrument(skip_all)] | |
| 31 | + | /// `None` for a count that could not be read: the row still has to be written, | |
| 32 | + | /// so the number says "we could not say" rather than claiming zero. | |
| 33 | + | pub async fn record( | |
| 34 | + | pool: &PgPool, | |
| 35 | + | user_id: UserId, | |
| 36 | + | follower_count: Option<i64>, | |
| 37 | + | subscriber_count: Option<i64>, | |
| 38 | + | ) -> Result<()> { | |
| 39 | + | sqlx::query( | |
| 40 | + | "INSERT INTO follower_exports (user_id, follower_count, subscriber_count) \ | |
| 41 | + | VALUES ($1, $2, $3)", | |
| 42 | + | ) | |
| 43 | + | .bind(user_id) | |
| 44 | + | .bind(follower_count) | |
| 45 | + | .bind(subscriber_count) | |
| 46 | + | .execute(pool) | |
| 47 | + | .await?; | |
| 48 | + | Ok(()) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// The creators an erasing subscriber's data may have reached, and when. | |
| 52 | + | /// | |
| 53 | + | /// The creators this person follows or subscribes to, intersected with the ones | |
| 54 | + | /// who exported *after* that relationship began. An export that ran before the | |
| 55 | + | /// follow could not have carried them, so it is not a creator to write to. | |
| 56 | + | /// | |
| 57 | + | /// `latest` is the most recent qualifying export, which is what an operator | |
| 58 | + | /// puts in the mail. Ordered by it, newest first: the creator most likely to | |
| 59 | + | /// still have the file open is the one to reach first. | |
| 60 | + | /// | |
| 61 | + | /// # What this cannot see, and it was decided rather than discovered | |
| 62 | + | /// | |
| 63 | + | /// [`super::follows`] hard-deletes on unfollow, so a subscriber who has already | |
| 64 | + | /// unfollowed leaves no relationship row to intersect against. Answering that | |
| 65 | + | /// case needs a stored set of exported addresses, hashed or otherwise, and Max | |
| 66 | + | /// declined the second copy on 2026-08-30. Reopen that decision if the case | |
| 67 | + | /// turns up in practice; do not quietly add the copy. | |
| 68 | + | #[tracing::instrument(skip_all)] | |
| 69 | + | pub async fn creators_to_contact(pool: &PgPool, subscriber_id: UserId) -> Result<Vec<Contacted>> { | |
| 70 | + | let rows = sqlx::query_as::<_, Contacted>( | |
| 71 | + | r" | |
| 72 | + | WITH related AS ( | |
| 73 | + | SELECT f.target_id AS creator_id, f.created_at AS since | |
| 74 | + | FROM follows f | |
| 75 | + | WHERE f.follower_id = $1 AND f.target_type = 'user' | |
| 76 | + | UNION ALL | |
| 77 | + | SELECT p.user_id AS creator_id, f.created_at AS since | |
| 78 | + | FROM follows f | |
| 79 | + | JOIN projects p ON p.id = f.target_id | |
| 80 | + | WHERE f.follower_id = $1 AND f.target_type = 'project' | |
| 81 | + | ) | |
| 82 | + | SELECT | |
| 83 | + | r.creator_id AS creator_id, | |
| 84 | + | u.username AS username, | |
| 85 | + | u.email AS email, | |
| 86 | + | MAX(e.requested_at) AS latest, | |
| 87 | + | COUNT(e.id) AS exports | |
| 88 | + | FROM related r | |
| 89 | + | JOIN follower_exports e | |
| 90 | + | ON e.user_id = r.creator_id AND e.requested_at >= r.since | |
| 91 | + | JOIN users u ON u.id = r.creator_id | |
| 92 | + | GROUP BY r.creator_id, u.username, u.email | |
| 93 | + | ORDER BY latest DESC | |
| 94 | + | ", | |
| 95 | + | ) | |
| 96 | + | .bind(subscriber_id) | |
| 97 | + | .fetch_all(pool) | |
| 98 | + | .await?; | |
| 99 | + | Ok(rows) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// One creator an erasure has to reach. | |
| 103 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 104 | + | pub struct Contacted { | |
| 105 | + | /// Who to write to. | |
| 106 | + | pub creator_id: UserId, | |
| 107 | + | /// Their handle, for the operator reading the list. | |
| 108 | + | pub username: String, | |
| 109 | + | /// Where to write. | |
| 110 | + | pub email: String, | |
| 111 | + | /// The most recent export that could have carried this subscriber. | |
| 112 | + | pub latest: DateTime<Utc>, | |
| 113 | + | /// How many qualifying exports they ran. A creator who exports weekly is a | |
| 114 | + | /// different follow-up from one who exported once. | |
| 115 | + | pub exports: i64, | |
| 116 | + | } |