1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "Records")
struct CreateRecordInput: Codable, Sendable {
let collection: String
let repo: String
var rkey: String?
let record: AnyCodable
}
struct CreateRecordResponse: Codable, Sendable {
var uri: String?
var cid: String?
}
struct PutRecordInput: Codable, Sendable {
let collection: String
let rkey: String
let record: AnyCodable
let repo: String
/// The CID the record must still have for the put to land.
var swapRecord: String?
}
struct DeleteRecordInput: Codable, Sendable {
let repo: String
let collection: String
let rkey: String
}
struct IndexRecordsInput: Codable, Sendable {
let uris: [String]
}
/// `com.atproto.repo.getRecord`'s answer.
struct GetRecordResponse: Codable, Sendable {
var uri: String?
var cid: String?
var value: AnyCodable?
}
/// One write inside a `com.atproto.repo.applyWrites` batch.
///
/// Every write names its rkey, so a batch that has to be retried addresses
/// the same records both times.
struct ApplyWrite: Codable, Sendable {
let type: String
let collection: String
let rkey: String
/// The record, for a create or an update.
let value: AnyCodable?
enum CodingKeys: String, CodingKey {
case type = "$type"
case collection
case rkey
case value
}
static func create(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite {
ApplyWrite(type: "com.atproto.repo.applyWrites#create", collection: collection, rkey: rkey, value: value)
}
static func update(collection: String, rkey: String, value: AnyCodable) -> ApplyWrite {
ApplyWrite(type: "com.atproto.repo.applyWrites#update", collection: collection, rkey: rkey, value: value)
}
static func delete(collection: String, rkey: String) -> ApplyWrite {
ApplyWrite(type: "com.atproto.repo.applyWrites#delete", collection: collection, rkey: rkey, value: nil)
}
}
struct ApplyWritesInput: Codable, Sendable {
let repo: String
let writes: [ApplyWrite]
}
struct ApplyWriteResult: Codable, Sendable {
var uri: String?
var cid: String?
}
struct ApplyWritesResponse: Codable, Sendable {
var results: [ApplyWriteResult]?
}
/// A page of `com.atproto.repo.listRecords`.
struct ListRecordsResponse: Codable, Sendable {
struct Record: Codable, Sendable {
let uri: String
var cid: String?
var value: AnyCodable?
}
var records: [Record]
var cursor: String?
}
struct DescribeRepoResponse: Codable, Sendable {
var collections: [String]?
}
struct DeleteAccountInput: Codable, Sendable {
let did: String
}
struct DeleteAccountResponse: Codable, Sendable {
var deleted: Int?
var done: Bool?
}
/// Writes go to the account's PDS as `com.atproto.repo.*` calls, with the
/// account's DID as the repo. Each one that lands is then handed to the
/// AppView's `social.grain.sync.indexRecords`, so the next read sees it
/// without waiting on the firehose.
extension XRPCClient {
/// How many URIs one `indexRecords` call takes.
static let indexBatchSize = 25
/// How many writes one `applyWrites` commit takes.
static let writeBatchSize = 200
/// Read the record at `uri` from its repo, at the account's PDS.
func getRecord(uri: String, auth: AuthContext? = nil) async throws -> GetRecordResponse {
let parts = uri.replacingOccurrences(of: "at://", with: "").split(separator: "/", maxSplits: 2).map(String.init)
guard parts.count == 3 else { throw XRPCError.invalidURL }
return try await query(
"com.atproto.repo.getRecord",
params: ["repo": parts[0], "collection": parts[1], "rkey": parts[2]],
auth: auth,
as: GetRecordResponse.self
)
}
/// Create a record, at `rkey` if one is given.
func createRecord(collection: String, repo: String, rkey: String? = nil, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse {
let response = try await procedure(
"com.atproto.repo.createRecord",
input: CreateRecordInput(collection: collection, repo: repo, rkey: rkey, record: record),
auth: auth,
as: CreateRecordResponse.self
)
await indexRecords([response.uri].compactMap(\.self), auth: auth)
return response
}
/// Create or overwrite the record at `rkey`. `repo` defaults to the
/// signed-in account's. With `swapRecord`, only if the record there
/// still has that CID.
func putRecord(collection: String, rkey: String, record: AnyCodable, repo: String? = nil, swapRecord: String? = nil, auth: AuthContext? = nil) async throws -> CreateRecordResponse {
let repo = repo ?? auth?.did ?? ""
let response = try await procedure(
"com.atproto.repo.putRecord",
input: PutRecordInput(collection: collection, rkey: rkey, record: record, repo: repo, swapRecord: swapRecord),
auth: auth,
as: CreateRecordResponse.self
)
await indexRecords([response.uri ?? Self.uri(repo, collection, rkey)], auth: auth)
return response
}
/// Apply every write in a single atomic PDS commit. Either all the records
/// land or none do: there is no partially-created gallery to clean up.
/// The records are then indexed, unless `indexing` is false.
func applyWrites(_ writes: [ApplyWrite], auth: AuthContext? = nil, indexing: Bool = true) async throws -> ApplyWritesResponse {
let repo = auth?.did ?? ""
let response = try await procedure(
"com.atproto.repo.applyWrites",
input: ApplyWritesInput(repo: repo, writes: writes),
auth: auth,
as: ApplyWritesResponse.self
)
if indexing {
await indexRecords(writes.map { Self.uri(repo, $0.collection, $0.rkey) }, auth: auth)
}
return response
}
func deleteRecord(collection: String, rkey: String, auth: AuthContext? = nil) async throws {
let repo = auth?.did ?? ""
try await procedure(
"com.atproto.repo.deleteRecord",
input: DeleteRecordInput(repo: repo, collection: collection, rkey: rkey),
auth: auth
)
await indexRecords([Self.uri(repo, collection, rkey)], auth: auth)
}
/// Ask the AppView to index `uris` now, deleted ones included, in batches
/// it accepts. Only Grain's own collections are its to index; a Bluesky
/// cross-post is not. A failure is logged and never fails the write it
/// follows: the firehose brings the record in the end.
func indexRecords(_ uris: [String], auth: AuthContext?) async {
let uris = uris.filter { $0.contains("/social.grain.") }
guard let auth, !uris.isEmpty else { return }
for start in stride(from: 0, to: uris.count, by: Self.indexBatchSize) {
let batch = Array(uris[start ..< min(start + Self.indexBatchSize, uris.count)])
do {
try await procedure("social.grain.sync.indexRecords", input: IndexRecordsInput(uris: batch), auth: auth)
} catch {
logger.error("indexRecords failed for \(batch.count) record(s): \(error)")
}
}
}
static func uri(_ repo: String, _ collection: String, _ rkey: String) -> String {
"at://\(repo)/\(collection)/\(rkey)"
}
/// Every record of the account's in `collection`, from its PDS.
func listAllRecords(collection: String, auth: AuthContext) async throws -> [ListRecordsResponse.Record] {
var records: [ListRecordsResponse.Record] = []
var cursor: String?
repeat {
var params = ["repo": auth.did, "collection": collection, "limit": "100"]
if let cursor {
params["cursor"] = cursor
}
let page = try await query("com.atproto.repo.listRecords", params: params, auth: auth, as: ListRecordsResponse.self)
records += page.records
cursor = page.records.isEmpty ? nil : page.cursor
} while cursor != nil
return records
}
/// Delete `uris` from the account's repo, `writeBatchSize` to a commit,
/// in the order given, each commit indexed unless `indexing` is false.
func deleteRecords(_ uris: [String], auth: AuthContext, indexing: Bool = true) async throws {
let writes: [ApplyWrite] = uris.compactMap { uri in
let parts = uri.split(separator: "/").map(String.init)
guard parts.count >= 3 else { return nil }
return .delete(collection: parts[parts.count - 2], rkey: parts[parts.count - 1])
}
for start in stride(from: 0, to: writes.count, by: Self.writeBatchSize) {
let batch = Array(writes[start ..< min(start + Self.writeBatchSize, writes.count)])
_ = try await applyWrites(batch, auth: auth, indexing: indexing)
}
}
/// Delete the account's gallery `rkey` and what goes with it, at its
/// PDS, as grain-serve deletes one for the web: the exif of the photos
/// it alone places, its items, those photos, the account's own
/// favorites and comments on it, then the gallery. Each commit is then
/// indexed.
func deleteGallery(rkey: String, auth: AuthContext) async throws {
let gallery = Self.uri(auth.did, "social.grain.gallery", rkey)
let photoPrefix = "at://\(auth.did)/social.grain.photo/"
func field(_ record: ListRecordsResponse.Record, _ name: String) -> String? {
record.value?.dictValue?[name]?.stringValue
}
let items = try await listAllRecords(collection: "social.grain.gallery.item", auth: auth)
let its = items.filter { field($0, "gallery") == gallery }
let elsewhere = Set(items.filter { field($0, "gallery") != gallery }.compactMap { field($0, "item") })
let photos = Set(its.compactMap { field($0, "item") }.filter { $0.hasPrefix(photoPrefix) && !elsewhere.contains($0) })
let exif = try await listAllRecords(collection: "social.grain.photo.exif", auth: auth)
.filter { field($0, "photo").map(photos.contains) ?? false }
let favorites = try await listAllRecords(collection: "social.grain.favorite", auth: auth)
.filter { field($0, "subject") == gallery }
let comments = try await listAllRecords(collection: "social.grain.comment", auth: auth)
.filter { field($0, "subject") == gallery }
let going = exif.map(\.uri).sorted()
+ its.map(\.uri).sorted()
+ photos.sorted()
+ favorites.map(\.uri).sorted()
+ comments.map(\.uri).sorted()
+ [gallery]
try await deleteRecords(going, auth: auth)
}
/// Delete the account's grain data: every record in each of its
/// `social.grain.*` collections, at its PDS, then ask grain-serve to
/// forget it, which it does once the PDS lists none of them. The
/// atproto account, and other apps' records in it, are untouched.
func deleteAccount(auth: AuthContext) async throws {
let repo = try await query("com.atproto.repo.describeRepo", params: ["repo": auth.did], auth: auth, as: DescribeRepoResponse.self)
for collection in repo.collections ?? [] where collection.hasPrefix("social.grain.") {
let uris = try await listAllRecords(collection: collection, auth: auth).map(\.uri)
try await deleteRecords(uris, auth: auth, indexing: false)
}
try await procedure(
"social.grain.server.deleteAccount",
input: DeleteAccountInput(did: auth.did),
auth: auth,
as: DeleteAccountResponse.self
)
}
}