Commit 1dea539c7c
1dea539c7cfcb5e45b5a45f5d133ceb0eeca101f
parent: 16c2392828
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-20 11:10 UTC
store: a token changing hands drops the old queue
AddPushDevice upserts on the token and moves user_id in place,
keeping the row id. Anything queued for the previous owner still
pointed at that row and would be delivered to the phone, and an alert
carries the repository name and item number in full — including a
private repository's, on a lock screen that now belongs to another
account. The app calls device add on every sign-in, which is exactly
when ownership changes.
Undelivered rows go with the ownership, in the same transaction.
Sent and dead-lettered rows are history and stay.
Ref #89
internal/store/push.go
+27 −5
| @@ -23,17 +23,39 @@ type PushDevice struct { |
| 23 | 23 | // hands the same one to whichever account signs in next. The id is read |
| 24 | 24 | // back by token rather than taken from LastInsertId, which SQLite leaves |
| 25 | 25 | // unchanged when the DO UPDATE arm fires instead of the INSERT. |
| 26 | // |
| 27 | // The row id survives that handover, so queue rows written for the |
| 28 | // previous owner would still be delivered to the device — and an alert |
| 29 | // carries the repository name and item number in full. Undelivered rows |
| 30 | // go with the ownership, in the same transaction; sent and dead-lettered |
| 31 | // rows are history and stay. |
| 26 | 32 | func (s *Store) AddPushDevice(userID int64, token, label string) (int64, error) { |
| 27 | | _, err := s.DB.Exec(` |
| 33 | tx, err := s.DB.Begin() |
| 34 | if err != nil { |
| 35 | return 0, err |
| 36 | } |
| 37 | defer tx.Rollback() |
| 38 | var prev int64 |
| 39 | if err := tx.QueryRow("SELECT user_id FROM push_devices WHERE token = ?", token).Scan(&prev); err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 40 | return 0, err |
| 41 | } |
| 42 | if _, err := tx.Exec(` |
| 28 | 43 | INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?) |
| 29 | 44 | ON CONFLICT(token) DO UPDATE SET user_id = excluded.user_id, label = excluded.label`, |
| 30 | | userID, token, label) |
| 31 | | if err != nil { |
| 45 | userID, token, label); err != nil { |
| 32 | 46 | return 0, err |
| 33 | 47 | } |
| 34 | 48 | var id int64 |
| 35 | | err = s.DB.QueryRow("SELECT id FROM push_devices WHERE token = ?", token).Scan(&id) |
| 36 | | return id, err |
| 49 | if err := tx.QueryRow("SELECT id FROM push_devices WHERE token = ?", token).Scan(&id); err != nil { |
| 50 | return 0, err |
| 51 | } |
| 52 | if prev != 0 && prev != userID { |
| 53 | if _, err := tx.Exec( |
| 54 | "DELETE FROM push_queue WHERE device_id = ? AND sent_at IS NULL AND failed_at IS NULL", id); err != nil { |
| 55 | return 0, err |
| 56 | } |
| 57 | } |
| 58 | return id, tx.Commit() |
| 37 | 59 | } |
| 38 | 60 | |
| 39 | 61 | func (s *Store) PushDevices(userID int64) ([]PushDevice, error) { |
internal/store/push_test.go
+61
| @@ -165,6 +165,67 @@ func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) { |
| 165 | 165 | } |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | // A token changing hands takes its undelivered queue with it. The row id |
| 169 | // survives the upsert, so anything queued for the previous owner would |
| 170 | // otherwise be delivered to a phone that now belongs to someone else — |
| 171 | // and an alert carries the repository name and item number in full. The |
| 172 | // iOS app calls device add on every sign-in, which is exactly when |
| 173 | // ownership changes. |
| 174 | func TestAddPushDeviceDropsThePreviousOwnersQueue(t *testing.T) { |
| 175 | s := pushFixture(t) |
| 176 | alice, err := s.CreateUser("alice", false) |
| 177 | if err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | bob, err := s.CreateUser("bob", false) |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | id, err := s.AddPushDevice(alice, "tok-a", "iphone") |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if err := s.EnqueuePush(alice, "alice/secret", "alice opened issue #1", "alice/secret/issues/1"); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | due, err := s.DuePush(20) |
| 192 | if err != nil || len(due) != 1 { |
| 193 | t.Fatalf("DuePush: %v %+v", err, due) |
| 194 | } |
| 195 | // A second row, already sent: history, not a pending delivery. |
| 196 | if err := s.EnqueuePush(alice, "alice/secret", "alice closed issue #1", "alice/secret/issues/1"); err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | sent, _ := s.DuePush(20) |
| 200 | if err := s.MarkPushSent(sent[len(sent)-1].ID); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | |
| 204 | if _, err := s.AddPushDevice(bob, "tok-a", "iphone"); err != nil { |
| 205 | t.Fatalf("re-register: %v", err) |
| 206 | } |
| 207 | if due, _ := s.DuePush(20); len(due) != 0 { |
| 208 | t.Fatalf("alice's pending push survived the handover: %+v", due) |
| 209 | } |
| 210 | var kept int |
| 211 | s.DB.QueryRow("SELECT COUNT(*) FROM push_queue WHERE device_id = ? AND sent_at IS NOT NULL", id).Scan(&kept) |
| 212 | if kept != 1 { |
| 213 | t.Fatalf("delivered rows deleted too: %d remain", kept) |
| 214 | } |
| 215 | |
| 216 | // Re-registering to the same owner leaves the queue alone: the app |
| 217 | // calls device add on every launch. |
| 218 | if err := s.EnqueuePush(bob, "bob/app", "bob opened issue #2", "bob/app/issues/2"); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | if _, err := s.AddPushDevice(bob, "tok-a", "iphone"); err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | if due, _ := s.DuePush(20); len(due) != 1 { |
| 225 | t.Fatalf("re-registering to the same owner dropped its own queue: %+v", due) |
| 226 | } |
| 227 | } |
| 228 | |
| 168 | 229 | func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) { |
| 169 | 230 | s := pushFixture(t) |
| 170 | 231 | uid, err := s.CreateUser("alice", false) |