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 {
2323// hands the same one to whichever account signs in next. The id is read
2424// back by token rather than taken from LastInsertId, which SQLite leaves
2525// 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.
2632func (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(`
2843 INSERT INTO push_devices (user_id, token, label) VALUES (?, ?, ?)
2944 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 {
3246 return 0, err
3347 }
3448 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()
3759}
3860
3961func (s *Store) PushDevices(userID int64) ([]PushDevice, error) {
internal/store/push_test.go +61
@@ -165,6 +165,67 @@ func TestEnqueuePushRespectsSettingAndDevices(t *testing.T) {
165165 }
166166}
167167
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.
174func 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
168229func TestDeletePushDeviceByTokenTakesItsQueue(t *testing.T) {
169230 s := pushFixture(t)
170231 uid, err := s.CreateUser("alice", false)