Skip to content

Commit 7d284bf

Browse files
committed
add profile-table persistence stores
1 parent 5214e08 commit 7d284bf

7 files changed

Lines changed: 725 additions & 0 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import Foundation
2+
import GRDB
3+
4+
/// Persistence for the durable profile publish queue: the current user's
5+
/// plaintext source avatar (`DBProfileAvatarSource`) and the per-conversation
6+
/// publish jobs (`DBProfilePublishJob`). Thin: round-trips and the queue queries
7+
/// (`nextReadyJob`, `nextSeq`, `earliestNextAttempt`, `supersedeOlderThan`) the
8+
/// drain loop needs. No state transitions or scheduling here; `ProfilePublisher`
9+
/// owns those.
10+
///
11+
/// Not wired into the app yet; introduced ahead of `ProfilePublisher`.
12+
protocol ProfilePublishStoreProtocol: Sendable {
13+
// Source image
14+
func setSource(_ source: DBProfileAvatarSource) async throws
15+
func source(inboxId: String) async throws -> DBProfileAvatarSource?
16+
func clearSource(inboxId: String) async throws
17+
18+
// Job queue
19+
func enqueue(_ job: DBProfilePublishJob) async throws
20+
func update(_ job: DBProfilePublishJob) async throws
21+
func job(id: String) async throws -> DBProfilePublishJob?
22+
/// The next non-done job whose `nextAttemptAt` has passed, lowest `seq`
23+
/// first. Fetch only; the publisher transitions state via `update`.
24+
func nextReadyJob(now: Date) async throws -> DBProfilePublishJob?
25+
func activeJobs() async throws -> [DBProfilePublishJob]
26+
func jobs(conversationId: String) async throws -> [DBProfilePublishJob]
27+
func nextSeq() async throws -> Int64
28+
func earliestNextAttempt() async throws -> Date?
29+
func deleteJob(id: String) async throws
30+
func deleteJobs(conversationId: String) async throws
31+
/// Drops non-done jobs for a conversation older than `seq`, so a newer
32+
/// publish intent supersedes stale ones.
33+
func supersedeOlderThan(conversationId: String, seq: Int64) async throws
34+
func deleteAll() async throws
35+
}
36+
37+
final class GRDBProfilePublishStore: ProfilePublishStoreProtocol {
38+
private let databaseWriter: any DatabaseWriter
39+
private let databaseReader: any DatabaseReader
40+
41+
init(databaseWriter: any DatabaseWriter, databaseReader: any DatabaseReader) {
42+
self.databaseWriter = databaseWriter
43+
self.databaseReader = databaseReader
44+
}
45+
46+
func setSource(_ source: DBProfileAvatarSource) async throws {
47+
try await databaseWriter.write { db in
48+
try source.save(db)
49+
}
50+
}
51+
52+
func source(inboxId: String) async throws -> DBProfileAvatarSource? {
53+
try await databaseReader.read { db in
54+
try DBProfileAvatarSource.fetchOne(db, inboxId: inboxId)
55+
}
56+
}
57+
58+
func clearSource(inboxId: String) async throws {
59+
try await databaseWriter.write { db in
60+
_ = try DBProfileAvatarSource.deleteOne(db, key: inboxId)
61+
}
62+
}
63+
64+
func enqueue(_ job: DBProfilePublishJob) async throws {
65+
try await databaseWriter.write { db in
66+
try job.save(db)
67+
}
68+
}
69+
70+
func update(_ job: DBProfilePublishJob) async throws {
71+
try await databaseWriter.write { db in
72+
try job.save(db)
73+
}
74+
}
75+
76+
func job(id: String) async throws -> DBProfilePublishJob? {
77+
try await databaseReader.read { db in
78+
try DBProfilePublishJob.fetchOne(db, id: id)
79+
}
80+
}
81+
82+
func nextReadyJob(now: Date) async throws -> DBProfilePublishJob? {
83+
try await databaseReader.read { db in
84+
try DBProfilePublishJob
85+
.filter(DBProfilePublishJob.Columns.state != ProfilePublishJobState.done.rawValue)
86+
.filter(DBProfilePublishJob.Columns.nextAttemptAt <= now)
87+
.order(DBProfilePublishJob.Columns.seq)
88+
.fetchOne(db)
89+
}
90+
}
91+
92+
func activeJobs() async throws -> [DBProfilePublishJob] {
93+
try await databaseReader.read { db in
94+
try DBProfilePublishJob
95+
.filter(DBProfilePublishJob.Columns.state != ProfilePublishJobState.done.rawValue)
96+
.order(DBProfilePublishJob.Columns.seq)
97+
.fetchAll(db)
98+
}
99+
}
100+
101+
func jobs(conversationId: String) async throws -> [DBProfilePublishJob] {
102+
try await databaseReader.read { db in
103+
try DBProfilePublishJob
104+
.filter(DBProfilePublishJob.Columns.conversationId == conversationId)
105+
.order(DBProfilePublishJob.Columns.seq)
106+
.fetchAll(db)
107+
}
108+
}
109+
110+
func nextSeq() async throws -> Int64 {
111+
try await databaseReader.read { db in
112+
try Int64.fetchOne(db, sql: "SELECT COALESCE(MAX(seq), 0) + 1 FROM profilePublishJob") ?? 1
113+
}
114+
}
115+
116+
func earliestNextAttempt() async throws -> Date? {
117+
try await databaseReader.read { db in
118+
try Date.fetchOne(
119+
db,
120+
sql: "SELECT MIN(nextAttemptAt) FROM profilePublishJob WHERE state <> ?",
121+
arguments: [ProfilePublishJobState.done.rawValue]
122+
)
123+
}
124+
}
125+
126+
func deleteJob(id: String) async throws {
127+
try await databaseWriter.write { db in
128+
_ = try DBProfilePublishJob.deleteOne(db, key: id)
129+
}
130+
}
131+
132+
func deleteJobs(conversationId: String) async throws {
133+
try await databaseWriter.write { db in
134+
_ = try DBProfilePublishJob
135+
.filter(DBProfilePublishJob.Columns.conversationId == conversationId)
136+
.deleteAll(db)
137+
}
138+
}
139+
140+
func supersedeOlderThan(conversationId: String, seq: Int64) async throws {
141+
try await databaseWriter.write { db in
142+
_ = try DBProfilePublishJob
143+
.filter(DBProfilePublishJob.Columns.conversationId == conversationId)
144+
.filter(DBProfilePublishJob.Columns.seq < seq)
145+
.deleteAll(db)
146+
}
147+
}
148+
149+
func deleteAll() async throws {
150+
try await databaseWriter.write { db in
151+
_ = try DBProfilePublishJob.deleteAll(db)
152+
_ = try DBProfileAvatarSource.deleteAll(db)
153+
}
154+
}
155+
}
156+
157+
actor InMemoryProfilePublishStore: ProfilePublishStoreProtocol {
158+
private var sourcesByInbox: [String: DBProfileAvatarSource] = [:]
159+
private var jobsById: [String: DBProfilePublishJob] = [:]
160+
161+
func setSource(_ source: DBProfileAvatarSource) {
162+
sourcesByInbox[source.inboxId] = source
163+
}
164+
165+
func source(inboxId: String) -> DBProfileAvatarSource? {
166+
sourcesByInbox[inboxId]
167+
}
168+
169+
func clearSource(inboxId: String) {
170+
sourcesByInbox[inboxId] = nil
171+
}
172+
173+
func enqueue(_ job: DBProfilePublishJob) {
174+
jobsById[job.id] = job
175+
}
176+
177+
func update(_ job: DBProfilePublishJob) {
178+
jobsById[job.id] = job
179+
}
180+
181+
func job(id: String) -> DBProfilePublishJob? {
182+
jobsById[id]
183+
}
184+
185+
func nextReadyJob(now: Date) -> DBProfilePublishJob? {
186+
jobsById.values
187+
.filter { $0.state != .done && $0.nextAttemptAt <= now }
188+
.min { $0.seq < $1.seq }
189+
}
190+
191+
func activeJobs() -> [DBProfilePublishJob] {
192+
jobsById.values
193+
.filter { $0.state != .done }
194+
.sorted { $0.seq < $1.seq }
195+
}
196+
197+
func jobs(conversationId: String) -> [DBProfilePublishJob] {
198+
jobsById.values
199+
.filter { $0.conversationId == conversationId }
200+
.sorted { $0.seq < $1.seq }
201+
}
202+
203+
func nextSeq() -> Int64 {
204+
(jobsById.values.map(\.seq).max() ?? 0) + 1
205+
}
206+
207+
func earliestNextAttempt() -> Date? {
208+
jobsById.values
209+
.filter { $0.state != .done }
210+
.map(\.nextAttemptAt)
211+
.min()
212+
}
213+
214+
func deleteJob(id: String) {
215+
jobsById[id] = nil
216+
}
217+
218+
func deleteJobs(conversationId: String) {
219+
for entry in jobsById where entry.value.conversationId == conversationId {
220+
jobsById[entry.key] = nil
221+
}
222+
}
223+
224+
func supersedeOlderThan(conversationId: String, seq: Int64) {
225+
for entry in jobsById where entry.value.conversationId == conversationId && entry.value.seq < seq {
226+
jobsById[entry.key] = nil
227+
}
228+
}
229+
230+
func deleteAll() {
231+
jobsById.removeAll()
232+
sourcesByInbox.removeAll()
233+
}
234+
}

0 commit comments

Comments
 (0)