Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
121 commits
Select commit Hold shift + click to select a range
b4625c4
test(ios): cover pending push opt-out
azooz2003-bit Aug 13, 2026
e7c9a6a
fix(ios): update push toggle optimistically
azooz2003-bit Aug 13, 2026
d2d527a
test(ios): make push toggle timing assertion deterministic
azooz2003-bit Aug 13, 2026
952586e
fix(ios): restore toast settings binding
azooz2003-bit Aug 13, 2026
207491f
fix(ios): remove retired toast settings toggle
azooz2003-bit Aug 13, 2026
63ed21a
fix(ios): initialize task composer setting
azooz2003-bit Aug 13, 2026
12cf0c5
fix(ios): tie push toggle mutation to view lifecycle
azooz2003-bit Aug 13, 2026
653a43d
fix(ios): bound push toggle mutations
azooz2003-bit Aug 13, 2026
a162e51
refactor(ios): isolate push preview mutation gate
azooz2003-bit Aug 13, 2026
6b69895
fix(ios): surface uncertain push toggle outcomes
azooz2003-bit Aug 13, 2026
7f4f7d3
Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off
azooz2003-bit Aug 13, 2026
5f4c064
fix(ios): bound push readiness reconciliation
azooz2003-bit Aug 13, 2026
4492cf1
fix(ios): serialize timed out push mutations
azooz2003-bit Aug 13, 2026
57bef7d
fix(ios): serialize push retries after reconciliation
azooz2003-bit Aug 13, 2026
e679e16
fix(ios): own push preference mutation in coordinator
azooz2003-bit Aug 13, 2026
5bc3c10
fix(ios): preempt stale push registration work
azooz2003-bit Aug 13, 2026
09868c8
test(push): require serialized opt-out mutation
azooz2003-bit Aug 13, 2026
4a5301d
test(push): cover ordered opt-out intents
azooz2003-bit Aug 14, 2026
90a97c1
fix(push): serialize notification intent mutations
azooz2003-bit Aug 14, 2026
8bde795
test(push): cover latest intent ordering
azooz2003-bit Aug 14, 2026
6300751
fix(push): make latest notification intent authoritative
azooz2003-bit Aug 14, 2026
9f86b86
test(push): deduplicate same-generation activation
azooz2003-bit Aug 14, 2026
4379d55
fix(push): coalesce repeated registration intents
azooz2003-bit Aug 14, 2026
d86f774
fix(push): coalesce pending notification intents
azooz2003-bit Aug 14, 2026
88b75b0
test(push): recover disabled startup cleanup
azooz2003-bit Aug 14, 2026
ebcdf1c
fix(push): recover interrupted opt-out cleanup
azooz2003-bit Aug 14, 2026
0b2802b
test(push): persist opt-out before cleanup
azooz2003-bit Aug 14, 2026
9177de8
fix(push): atomically commit coordinator intent
azooz2003-bit Aug 14, 2026
87da95b
refactor(push): align service package conventions
azooz2003-bit Aug 14, 2026
7c7f86f
test(push): cover startup ownership and stale snapshots
azooz2003-bit Aug 14, 2026
0e9a3fa
fix(push): fence in-flight cleanup and stale snapshots
azooz2003-bit Aug 14, 2026
d5818e9
test(push): cover reenable generation during cleanup
azooz2003-bit Aug 14, 2026
cdffcba
fix(push): submit every current enable generation
azooz2003-bit Aug 14, 2026
15270dc
test(push): preserve enable after caller cancellation
azooz2003-bit Aug 14, 2026
5f2c42b
fix(push): let intent drains outlive caller cancellation
azooz2003-bit Aug 14, 2026
66b9763
test(push): preserve absent preference on startup
azooz2003-bit Aug 14, 2026
9c94426
fix(push): require explicit opt-out for startup cleanup
azooz2003-bit Aug 14, 2026
f5c9664
test(push): preserve denied reenable generation
azooz2003-bit Aug 14, 2026
6bdd586
fix(push): propagate denied enable generation
azooz2003-bit Aug 14, 2026
b93b7e0
fix(push): unify preference mutation ordering
azooz2003-bit Aug 14, 2026
e8a3df1
chore(push): isolate intent kind type
azooz2003-bit Aug 14, 2026
845883c
test(push): cancel queued mutation waiter
azooz2003-bit Aug 14, 2026
886a28c
fix(push): drop cancelled mutation waiters
azooz2003-bit Aug 14, 2026
18c5584
test(push): accept initial coordinator generation
azooz2003-bit Aug 14, 2026
abe6ac9
fix(push): accept generation zero startup intent
azooz2003-bit Aug 14, 2026
be49424
fix(push): recheck cancellation at gate handoff
azooz2003-bit Aug 14, 2026
bf861ab
test(push): recover cancelled queued registration
azooz2003-bit Aug 14, 2026
a9d3eec
fix(push): recover cancelled registration attempts
azooz2003-bit Aug 14, 2026
c2c06d7
test(push): fix cancellation regression setup
azooz2003-bit Aug 14, 2026
59ded95
test(push): bound stalled settings mutation
azooz2003-bit Aug 14, 2026
cb19142
fix(push): bound settings mutation lifetime
azooz2003-bit Aug 14, 2026
8631125
chore(push): isolate timeout outcome type
azooz2003-bit Aug 14, 2026
fa2d641
test(push): retry timed out enable intent
azooz2003-bit Aug 14, 2026
15f23dd
fix(push): permit retry after settings timeout
azooz2003-bit Aug 14, 2026
81f03ee
test(push): cover late prompt and worker cancellation
azooz2003-bit Aug 14, 2026
72d7ee6
fix(push): reconcile late authorization and cancel workers
azooz2003-bit Aug 14, 2026
7c6ef3c
test(push): isolate mutation timeout generations
azooz2003-bit Aug 14, 2026
f1582d5
test(push): preserve queued sign-out cleanup on cancellation
azooz2003-bit Aug 14, 2026
84bd0cc
fix(push): persist sign-out cleanup before mutation gate
azooz2003-bit Aug 14, 2026
62a72a7
test(push): cover stale registration and public enable timeout
azooz2003-bit Aug 14, 2026
197fb04
fix(push): fence direct registration commits and bound enables
azooz2003-bit Aug 14, 2026
015f353
test(push): fence opt-out cleanup to persisted owner
azooz2003-bit Aug 14, 2026
a441e85
fix(push): require persisted owner for opt-out cleanup
azooz2003-bit Aug 14, 2026
eb29f93
test(push): seed cleanup owner in queued sign-out
azooz2003-bit Aug 14, 2026
8546b3d
fix(push): reject unproven legacy cleanup credentials
azooz2003-bit Aug 14, 2026
75937e4
fix(push): store boolean settings mutation task
azooz2003-bit Aug 14, 2026
87c0033
test(push): cover stalled intent and timeout worker ownership
azooz2003-bit Aug 14, 2026
25ba6fd
fix(push): retain bounded mutation lanes until completion
azooz2003-bit Aug 14, 2026
1130e64
refactor(push): keep intent lane state flat
azooz2003-bit Aug 14, 2026
f3b831f
test(push): require timed-out intent to reach service
azooz2003-bit Aug 14, 2026
faa340e
fix(push): commit intents before bounded reconciliation
azooz2003-bit Aug 14, 2026
1f9c96d
test(push): require bounded timeout recovery
azooz2003-bit Aug 14, 2026
6e98cee
fix(push): bound timeout recovery lanes
azooz2003-bit Aug 14, 2026
0cfa6da
test(push): cover late intent and timeout winner races
azooz2003-bit Aug 14, 2026
6832304
fix(push): fence late intents and timeout races
azooz2003-bit Aug 14, 2026
5528198
test(push): cover startup drain and same-lane recovery
azooz2003-bit Aug 14, 2026
01fdd8e
fix(push): bound same-lane recovery and startup cleanup
azooz2003-bit Aug 14, 2026
6019041
test(push): wait for same-lane recovery state
azooz2003-bit Aug 14, 2026
61b1afc
test(push): require fresh bounded recovery after timeout
azooz2003-bit Aug 14, 2026
9b0f659
fix(push): bound cached registration recovery
azooz2003-bit Aug 14, 2026
890b6b9
refactor(push): isolate intent worker type
azooz2003-bit Aug 14, 2026
86c89f5
test(push): protect newer registration from stale sign-out
azooz2003-bit Aug 14, 2026
e599af9
fix(push): order sign-out with newer registration
azooz2003-bit Aug 14, 2026
c33317a
refactor(push): use one reconciliation worker
azooz2003-bit Aug 14, 2026
d7ebaae
test(ios): recover timed-out push opt-out
azooz2003-bit Aug 14, 2026
8a8fb0b
fix(ios): recover timed-out push cleanup
azooz2003-bit Aug 14, 2026
8a982c1
Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off
azooz2003-bit Aug 14, 2026
c9cc7c1
refactor(ios): reduce push toggle fix scope
azooz2003-bit Aug 14, 2026
4666aa7
test(ios): cover pending push opt-out
azooz2003-bit Aug 14, 2026
ed6e5e0
fix(ios): update push toggle optimistically
azooz2003-bit Aug 14, 2026
dc37932
test(ios): signal pending push mutation
azooz2003-bit Aug 14, 2026
253e670
fix(ios): keep push toggle interactive
azooz2003-bit Aug 14, 2026
2f0e084
test(ios): bound pending push fixture
azooz2003-bit Aug 14, 2026
1834813
test(ios): isolate push fixture state
azooz2003-bit Aug 14, 2026
71818d7
fix(ios): serialize push toggle mutations
azooz2003-bit Aug 14, 2026
e6d722d
fix(ios): preserve queued push intent
azooz2003-bit Aug 14, 2026
38ffb32
fix(ios): honor resolved push state
azooz2003-bit Aug 14, 2026
9aae5b3
test(ios): cover superseded push intent
azooz2003-bit Aug 14, 2026
c9be524
fix(ios): supersede stale push intents
azooz2003-bit Aug 14, 2026
3607cc8
fix(ios): reconcile started push mutations
azooz2003-bit Aug 14, 2026
1fa4230
refactor(push): own intent reconciliation
azooz2003-bit Aug 14, 2026
9800bdd
test(push): require immediate opt-out cleanup
azooz2003-bit Aug 14, 2026
d34c247
fix(push): let opt-outs bypass stale work
azooz2003-bit Aug 14, 2026
d9a4ea5
fix(push): authorize before backend reconciliation
azooz2003-bit Aug 14, 2026
c32c631
test(push): bound pending cleanup storage
azooz2003-bit Aug 14, 2026
4364c2d
fix(push): bound pending cleanup state
azooz2003-bit Aug 14, 2026
708bc6b
fix(push): preserve overflow cleanup obligations
azooz2003-bit Aug 15, 2026
48730ca
Merge origin/main into fix-ios-push-toggle-off
azooz2003-bit Aug 15, 2026
3f6a681
fix(push): page durable cleanup overflow
azooz2003-bit Aug 15, 2026
0c443b5
fix(push): discard stale cleanup index entries
azooz2003-bit Aug 15, 2026
3a25ecf
fix(push): continue paged cleanup drains
azooz2003-bit Aug 15, 2026
d3841ca
fix(push): supersede direct cleanup retries
azooz2003-bit Aug 15, 2026
8dc9926
fix(push): preserve cleanup intent across retries
azooz2003-bit Aug 15, 2026
5146847
fix(push): preserve generation through cleanup recovery
azooz2003-bit Aug 15, 2026
d212b57
fix(push): index durable cleanup obligations
azooz2003-bit Aug 15, 2026
d1ef41b
fix(push): make cleanup migration transactional
azooz2003-bit Aug 15, 2026
b4ff551
fix(push): close notification intent races
azooz2003-bit Aug 15, 2026
8fa5021
fix(push): bound recovery workers
azooz2003-bit Aug 15, 2026
869b992
fix(push): preserve ambiguous cleanup state
azooz2003-bit Aug 15, 2026
8677b43
fix(push): await bounded opt-out cleanup
azooz2003-bit Aug 15, 2026
52e1b9c
Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off
azooz2003-bit Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ enum AuthPhase: String, Sendable, Hashable {
case listTeams = "list_teams"
case postSignIn = "post_sign_in"
case accountDeletion = "account_deletion"
case pushRegistrationSession = "push_registration_session"
case pushUnregistrationSession = "push_unregistration_session"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import Foundation
import SQLite3

struct PendingUnregister: Codable, Hashable, Sendable {
let tokenHex: String
let accountID: String
}

/// Indexed durable storage for privacy-sensitive push cleanup obligations.
///
/// UserDefaults retains its domain in memory and is a poor fit for a queue that
/// can outlive several accounts. SQLite keeps the working set bounded: retries
/// read at most their requested batch, token reassignment uses an indexed
/// delete, and the uniqueness constraint compacts duplicate obligations.
final class PendingUnregisterStore {
private var database: OpaquePointer?

init(databaseURL: URL) throws {
try FileManager.default.createDirectory(
at: databaseURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var opened: OpaquePointer?
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(databaseURL.path, &opened, flags, nil) == SQLITE_OK,
let opened else {
if let opened { sqlite3_close_v2(opened) }
throw PendingUnregisterStoreError.openFailed
}
database = opened
do {
try execute("PRAGMA journal_mode=WAL;")
try execute("PRAGMA synchronous=FULL;")
try execute("PRAGMA auto_vacuum=INCREMENTAL;")
try execute(
"""
CREATE TABLE IF NOT EXISTS pending_unregister (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
token_hex TEXT NOT NULL,
account_id TEXT NOT NULL,
UNIQUE(token_hex, account_id)
);
"""
)
try execute(
"""
CREATE INDEX IF NOT EXISTS pending_unregister_account_sequence
ON pending_unregister(account_id, sequence);
"""
)
try execute(
"""
CREATE INDEX IF NOT EXISTS pending_unregister_token
ON pending_unregister(token_hex);
"""
)
} catch {
sqlite3_close_v2(opened)
database = nil
throw error
}
}

deinit {
if let database {
sqlite3_close_v2(database)
}
}

@discardableResult
func insert(_ entry: PendingUnregister) -> Bool {
guard let statement = prepare(
"""
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
VALUES (?, ?);
"""
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(entry.tokenHex, to: statement, at: 1),
bind(entry.accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
return true
}

/// Inserts a legacy queue in one durable transaction. This keeps launch
/// migration linear and pays at most one FULL-synchronous commit.
@discardableResult
func insertAll(_ entries: [PendingUnregister]) -> Bool {
guard !entries.isEmpty else { return true }
guard sqlite3_exec(
database,
"BEGIN IMMEDIATE;",
nil,
nil,
nil
) == SQLITE_OK else { return false }
var committed = false
defer {
if !committed {
_ = sqlite3_exec(database, "ROLLBACK;", nil, nil, nil)
}
}
guard let statement = prepare(
"""
INSERT OR IGNORE INTO pending_unregister(token_hex, account_id)
VALUES (?, ?);
"""
) else { return false }
defer { sqlite3_finalize(statement) }
for entry in entries {
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
guard bind(entry.tokenHex, to: statement, at: 1),
bind(entry.accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
}
guard sqlite3_exec(database, "COMMIT;", nil, nil, nil) == SQLITE_OK else {
return false
}
committed = true
return true
}

func batch(accountID: String, limit: Int) -> [PendingUnregister] {
guard limit > 0, let statement = prepare(
"""
SELECT token_hex, account_id
FROM pending_unregister
WHERE account_id = ?
ORDER BY sequence
LIMIT ?;
"""
) else { return [] }
defer { sqlite3_finalize(statement) }
guard bind(accountID, to: statement, at: 1),
sqlite3_bind_int64(statement, 2, Int64(limit)) == SQLITE_OK else {
return []
}
var result: [PendingUnregister] = []
result.reserveCapacity(limit)
while sqlite3_step(statement) == SQLITE_ROW {
guard let token = sqlite3_column_text(statement, 0),
let account = sqlite3_column_text(statement, 1) else {
continue
}
result.append(PendingUnregister(
tokenHex: String(cString: token),
accountID: String(cString: account)
))
}
return result
}

@discardableResult
func remove(tokenHex: String, accountID: String) -> Bool {
guard let statement = prepare(
"""
DELETE FROM pending_unregister
WHERE token_hex = ? AND account_id = ?;
"""
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(tokenHex, to: statement, at: 1),
bind(accountID, to: statement, at: 2),
sqlite3_step(statement) == SQLITE_DONE else { return false }
compactFreedPages()
return true
}

@discardableResult
func removeAll(tokenHex: String) -> Bool {
guard let statement = prepare(
"DELETE FROM pending_unregister WHERE token_hex = ?;"
) else { return false }
defer { sqlite3_finalize(statement) }
guard bind(tokenHex, to: statement, at: 1),
sqlite3_step(statement) == SQLITE_DONE else { return false }
compactFreedPages()
return true
}

var hasEntries: Bool {
guard let statement = prepare(
"SELECT 1 FROM pending_unregister LIMIT 1;"
) else { return false }
defer { sqlite3_finalize(statement) }
return sqlite3_step(statement) == SQLITE_ROW
}

private func compactFreedPages() {
_ = sqlite3_exec(database, "PRAGMA incremental_vacuum(4);", nil, nil, nil)
}

private func execute(_ sql: String) throws {
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
throw PendingUnregisterStoreError.schemaFailed
}
}

private func prepare(_ sql: String) -> OpaquePointer? {
var statement: OpaquePointer?
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
return nil
}
return statement
}

private func bind(
_ value: String,
to statement: OpaquePointer,
at index: Int32
) -> Bool {
value.withCString { pointer in
sqlite3_bind_text(
statement,
index,
pointer,
-1,
unsafeBitCast(-1, to: sqlite3_destructor_type.self)
) == SQLITE_OK
}
}
}

private enum PendingUnregisterStoreError: Error {
case openFailed
case schemaFailed
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ public protocol PushRegistering: Sendable {
/// removing it server-side on disable.
func setEnabled(_ enabled: Bool) async

/// Commits a coordinator-owned preference in generation order. Opt-out
/// cleanup runs in an app-owned worker and this call awaits its bounded
/// attempt without transferring cancellation ownership. Enabling is
/// persisted here but must wait for ``reconcileEnabledIntent(generation:)``
/// after iOS notification authorization succeeds.
func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async

/// Starts backend registration for the current enabled intent after the
/// coordinator has confirmed that iOS permits notification delivery.
func reconcileEnabledIntent(generation: UInt64) async

/// Cache and (when opted in) upload a freshly registered APNs device token.
func register(deviceToken: Data) async

Expand Down
Loading