diff --git a/Sources/LatrKit/Library/BookmarkLibrary.swift b/Sources/LatrKit/Library/BookmarkLibrary.swift new file mode 100644 index 0000000..01c79c2 --- /dev/null +++ b/Sources/LatrKit/Library/BookmarkLibrary.swift @@ -0,0 +1,219 @@ +import Foundation + +public extension SavedLibrary { + func bookmarks(limit: Int = 50, startingAfter cursor: String? = nil) async throws -> BookmarkList { + let page: RecordList = try await repository.listRecords( + in: repositoryDID, + collection: .bookmark, + limit: min(max(limit, 1), 100), + startingAfter: cursor + ) + let metadataByKey = try await bookmarkMetadataByKey() + var views: [BookmarkView] = [] + for record in page.records { + guard let key = LexiconURI.recordKey(from: record.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: record.uri) + } + let metadata = metadataByKey[key].flatMap { bookmarkMetadata($0, matches: record) ? $0 : nil } + views.append(BookmarkView(record: record, metadataRecord: metadata)) + } + return BookmarkList(records: views, cursor: page.cursor) + } + + func bookmark(subject rawSubject: String) async throws -> BookmarkView? { + let subject = try validatedBookmarkSubject(rawSubject) + var matches: [RepositoryRecord] = [] + var cursor: String? + repeat { + let page: RecordList = try await repository.listRecords( + in: repositoryDID, collection: .bookmark, limit: 100, startingAfter: cursor + ) + matches.append(contentsOf: page.records.filter { $0.value.subject == subject }) + cursor = page.cursor + } while cursor != nil + guard let selected = canonicalBookmark(from: matches) else { return nil } + guard let key = LexiconURI.recordKey(from: selected.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: selected.uri) + } + let metadata: RepositoryRecord? = try await repository.record( + in: repositoryDID, collection: .bookmarkMetadata, withKey: key + ) + return BookmarkView( + record: selected, + metadataRecord: metadata.flatMap { bookmarkMetadata($0, matches: selected) ? $0 : nil } + ) + } + + func syncBookmarkMetadata( + limit rawLimit: Int = 50, + startingAfter cursor: String? = nil + ) async throws -> BookmarkMetadataSyncSummary { + let page: RecordList = try await repository.listRecords( + in: repositoryDID, + collection: .bookmark, + limit: min(max(rawLimit, 1), 100), + startingAfter: cursor + ) + let metadataByKey = try await bookmarkMetadataByKey() + var summary = BookmarkMetadataSyncSummary(scanned: page.records.count, cursor: page.cursor) + var writes: [RepositoryWrite] = [] + + for bookmark in page.records { + guard let key = LexiconURI.recordKey(from: bookmark.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: bookmark.uri) + } + if let metadata = metadataByKey[key] { + if bookmarkMetadata(metadata, matches: bookmark) { + summary.reused += 1 + } else { + summary.skippedConflict += 1 + } + continue + } + + let metadata = BookmarkMetadata( + bookmarkUri: bookmark.uri, + subject: bookmark.value.subject, + state: .unread + ) + writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: metadata)) + } + + if !writes.isEmpty { + try await repository.applyWrites(in: repositoryDID, writes: writes) + summary.created = writes.count + } + return summary + } + + @discardableResult + func saveBookmark(subject rawSubject: String, tags: [String]? = nil) async throws -> BookmarkView { + let subject = try validatedBookmarkSubject(rawSubject) + let stableTags = tags.map { Array(Set($0)).sorted() } + if let existing = try await bookmark(subject: subject) { + guard let key = LexiconURI.recordKey(from: existing.uri) else { + throw SavedLibraryError.invalidStoredRecord(uri: existing.uri) + } + var writes: [RepositoryWrite] = [] + var nextBookmark = existing.value + if let stableTags { + nextBookmark.tags = Array(Set((nextBookmark.tags ?? []) + stableTags)).sorted() + if nextBookmark.tags != existing.value.tags { + writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid)) + } + } + if existing.metadataRecord == nil { + let metadata = BookmarkMetadata(bookmarkUri: existing.uri, subject: subject, state: .unread) + writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: metadata)) + } + if !writes.isEmpty { try await repository.applyWrites(in: repositoryDID, writes: writes) } + return try await bookmark(subject: subject) ?? existing + } + + let key = TID.now() + let uri = "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)" + let bookmark = CommunityBookmark(subject: subject, createdAt: Timestamp.iso8601Now(), tags: stableTags) + let metadata = BookmarkMetadata(bookmarkUri: uri, subject: subject, state: .unread) + try await repository.applyWrites( + in: repositoryDID, + writes: [ + try .creating(collection: .bookmark, key: key, value: bookmark), + try .creating(collection: .bookmarkMetadata, key: key, value: metadata), + ] + ) + guard let created = try await self.bookmark(subject: subject) else { + throw SavedLibraryError.invalidStoredRecord(uri: uri) + } + return created + } + + func setState(ofBookmarkURI uri: String, to state: SavedItemState) async throws { + guard let key = LexiconURI.recordKey(from: uri) else { throw SavedLibraryError.bookmarkNotFound } + let bookmark: RepositoryRecord? = try await repository.record( + in: repositoryDID, collection: .bookmark, withKey: key + ) + guard let bookmark else { throw SavedLibraryError.bookmarkNotFound } + let current: RepositoryRecord? = try await repository.record( + in: repositoryDID, collection: .bookmarkMetadata, withKey: key + ) + if let current { + guard bookmarkMetadata(current, matches: bookmark) else { + throw SavedLibraryError.invalidStoredRecord(uri: current.uri) + } + var next = current.value + next.state = state + try await repository.applyWrites(in: repositoryDID, writes: [ + try .updating(collection: .bookmarkMetadata, key: key, value: next, swapRecord: current.cid), + ]) + } else { + let metadata = BookmarkMetadata(bookmarkUri: bookmark.uri, subject: bookmark.value.subject, state: state) + try await repository.applyWrites(in: repositoryDID, writes: [ + try .creating(collection: .bookmarkMetadata, key: key, value: metadata), + ]) + } + } + + func removeBookmark(uri: String) async throws { + guard let key = LexiconURI.recordKey(from: uri) else { throw SavedLibraryError.bookmarkNotFound } + guard let bookmark: RepositoryRecord = try await repository.record( + in: repositoryDID, collection: .bookmark, withKey: key + ) else { throw SavedLibraryError.bookmarkNotFound } + let metadata: RepositoryRecord? = try await repository.record( + in: repositoryDID, collection: .bookmarkMetadata, withKey: key + ) + var writes: [RepositoryWrite] = [ + .delete(collection: .bookmark, key: key, swapRecord: bookmark.cid), + ] + if let metadata, bookmarkMetadata(metadata, matches: bookmark) { + writes.append(.delete(collection: .bookmarkMetadata, key: key, swapRecord: metadata.cid)) + } + try await repository.applyWrites(in: repositoryDID, writes: writes) + } + + private func validatedBookmarkSubject(_ raw: String) throws -> String { + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.utf8.count <= 8192 else { throw SavedLibraryError.invalidURL } + if value.hasPrefix("at://") { + guard value.split(separator: "/").count >= 4 else { throw SavedLibraryError.invalidURL } + return value + } + guard let scheme = URLComponents(string: value)?.scheme?.lowercased(), ["http", "https"].contains(scheme) else { + throw SavedLibraryError.invalidURL + } + return value + } + + private func canonicalBookmark(from records: [RepositoryRecord]) -> RepositoryRecord? { + records.sorted { + if $0.value.createdAt != $1.value.createdAt { return $0.value.createdAt < $1.value.createdAt } + return $0.uri < $1.uri + }.first + } + + private func bookmarkMetadataByKey() async throws -> [String: RepositoryRecord] { + var metadataByKey: [String: RepositoryRecord] = [:] + var cursor: String? + repeat { + let page: RecordList = try await repository.listRecords( + in: repositoryDID, + collection: .bookmarkMetadata, + limit: 100, + startingAfter: cursor + ) + for metadata in page.records { + if let key = LexiconURI.recordKey(from: metadata.uri) { + metadataByKey[key] = metadata + } + } + cursor = page.cursor + } while cursor != nil + return metadataByKey + } + + private func bookmarkMetadata( + _ metadata: RepositoryRecord, + matches bookmark: RepositoryRecord + ) -> Bool { + metadata.value.bookmarkUri == bookmark.uri && metadata.value.subject == bookmark.value.subject + } +} diff --git a/Sources/LatrKit/Library/BookmarkMigration.swift b/Sources/LatrKit/Library/BookmarkMigration.swift new file mode 100644 index 0000000..f99e93d --- /dev/null +++ b/Sources/LatrKit/Library/BookmarkMigration.swift @@ -0,0 +1,162 @@ +import Foundation + +public struct BookmarkMigrationSummary: Codable, Sendable, Equatable { + public var ok = true + public var scanned = 0 + public var created = 0 + public var reused = 0 + public var duplicates = 0 + public var skippedConflict = 0 + public var cached = 0 + public var retired = 0 + public var cursor: String? + + public init() {} +} + +private struct LegacyItemSource: Sendable { + let collection: LexiconCollection + let record: RepositoryRecord +} + +private struct LegacyExternalSource: Sendable { + let collection: LexiconCollection + let record: RepositoryRecord +} + +public extension SavedLibrary { + func migrateBookmarks(limit rawLimit: Int = 25, cursor rawCursor: String? = nil) async throws -> BookmarkMigrationSummary { + let limit = min(max(rawLimit, 1), 100) + let subjectCursor = rawCursor?.trimmingCharacters(in: .whitespacesAndNewlines) + let externalSources = try await allExternalMigrationSources() + let externalByURI = Dictionary(uniqueKeysWithValues: externalSources.map { ($0.record.uri, $0) }) + let itemSources = try await allItemMigrationSources().sorted { $0.record.uri < $1.record.uri } + + let candidates = itemSources.compactMap { source -> (LegacyItemSource, String)? in + guard let subject = migrationSubject(for: source.record.value, externalByURI: externalByURI) else { return nil } + return (source, subject) + } + let grouped = Dictionary(grouping: candidates, by: { $0.1 }) + .sorted { $0.key < $1.key } + let remaining = grouped.filter { subjectCursor == nil || $0.key > subjectCursor! } + let page = Array(remaining.prefix(limit)) + var summary = BookmarkMigrationSummary() + + for (subject, entries) in page { + summary.scanned += entries.count + if entries.count > 1 { summary.duplicates += entries.count - 1 } + let items = entries.map(\.0) + let wrappers = Set(items.map(\.record.value.subjectUri)).compactMap { externalByURI[$0] } + let notes = Set(items.compactMap { $0.record.value.note?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }) + let hasUnknownFields = items.contains { !$0.record.value.unknownFields.isEmpty } + || wrappers.contains { !$0.record.value.unknownFields.isEmpty } + if notes.count > 1 || hasUnknownFields { + summary.skippedConflict += entries.count + continue + } + + let existing = try await bookmark(subject: subject) + let key = existing.flatMap { LexiconURI.recordKey(from: $0.uri) } ?? TID.now() + let bookmarkURI = existing?.uri ?? "at://\(repositoryDID)/\(LexiconCollection.bookmark.identifier)/\(key)" + let tags = Array(Set(items.flatMap { $0.record.value.tags ?? [] })).sorted() + let createdAt = items.map(\.record.value.savedAt).min() ?? Timestamp.iso8601Now() + let state: SavedItemState = items.contains { $0.record.value.state != .archived } ? .unread : .archived + let lastOpenedAt = items.compactMap(\.record.value.lastOpenedAt).max() + let legacyURIs = items.map(\.record.uri).sorted() + let note = notes.first + let nextBookmark = CommunityBookmark( + subject: subject, + createdAt: min(existing?.value.createdAt ?? createdAt, createdAt), + tags: Array(Set((existing?.value.tags ?? []) + tags)).sorted(), + unknownFields: existing?.value.unknownFields ?? [:] + ) + let currentMetadata = existing?.metadataRecord + if let currentNote = currentMetadata?.value.note?.trimmingCharacters(in: .whitespacesAndNewlines), + !currentNote.isEmpty, let note, currentNote != note { + summary.skippedConflict += entries.count + continue + } + var nextMetadata = currentMetadata?.value ?? BookmarkMetadata(bookmarkUri: bookmarkURI, subject: subject) + nextMetadata.state = currentMetadata?.value.state ?? state + nextMetadata.note = currentMetadata?.value.note ?? note + nextMetadata.lastOpenedAt = max(currentMetadata?.value.lastOpenedAt ?? "", lastOpenedAt ?? "").nilIfEmpty + nextMetadata.legacyItemUris = Array(Set((nextMetadata.legacyItemUris ?? []) + legacyURIs)).sorted() + + var writes: [RepositoryWrite] = [] + if let existing { + writes.append(try .updating(collection: .bookmark, key: key, value: nextBookmark, swapRecord: existing.cid)) + summary.reused += 1 + } else { + writes.append(try .creating(collection: .bookmark, key: key, value: nextBookmark)) + summary.created += 1 + } + if let currentMetadata { + writes.append(try .updating(collection: .bookmarkMetadata, key: key, value: nextMetadata, swapRecord: currentMetadata.cid)) + } else { + writes.append(try .creating(collection: .bookmarkMetadata, key: key, value: nextMetadata)) + } + for item in items { + guard let itemKey = LexiconURI.recordKey(from: item.record.uri) else { continue } + writes.append(.delete(collection: item.collection, key: itemKey, swapRecord: item.record.cid)) + } + for wrapper in wrappers { + guard let wrapperKey = LexiconURI.recordKey(from: wrapper.record.uri) else { continue } + writes.append(.delete(collection: wrapper.collection, key: wrapperKey, swapRecord: wrapper.record.cid)) + } + do { + try await repository.applyWrites(in: repositoryDID, writes: writes) + summary.retired += items.count + wrappers.count + } catch RepositoryClientError.conflict { + summary.skippedConflict += entries.count + if existing == nil { summary.created -= 1 } else { summary.reused -= 1 } + } + } + + summary.cursor = page.count < remaining.count ? page.last?.key : nil + return summary + } + + private func migrationSubject( + for item: SavedItem, + externalByURI: [String: LegacyExternalSource] + ) -> String? { + if let wrapper = externalByURI[item.subjectUri] { + let original = wrapper.record.value.url.trimmingCharacters(in: .whitespacesAndNewlines) + if original.hasPrefix("https://") || original.hasPrefix("http://") { return original } + return wrapper.record.value.normalizedUrl + } + if let linked = item.linkedWebUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + linked.hasPrefix("https://") || linked.hasPrefix("http://") { return linked } + return item.subjectUri + } + + private func allItemMigrationSources() async throws -> [LegacyItemSource] { + var result: [LegacyItemSource] = [] + for collection in [LexiconCollection.savedItem, .legacySavedItem] { + var cursor: String? + repeat { + let page: RecordList = try await repository.listRecords(in: repositoryDID, collection: collection, limit: 100, startingAfter: cursor) + result.append(contentsOf: page.records.map { LegacyItemSource(collection: collection, record: $0) }) + cursor = page.cursor + } while cursor != nil + } + return result + } + + private func allExternalMigrationSources() async throws -> [LegacyExternalSource] { + var result: [LegacyExternalSource] = [] + for collection in [LexiconCollection.external, .legacyExternal] { + var cursor: String? + repeat { + let page: RecordList = try await repository.listRecords(in: repositoryDID, collection: collection, limit: 100, startingAfter: cursor) + result.append(contentsOf: page.records.map { LegacyExternalSource(collection: collection, record: $0) }) + cursor = page.cursor + } while cursor != nil + } + return result + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/LatrKit/Library/SavedLibraryError.swift b/Sources/LatrKit/Library/SavedLibraryError.swift index e1f1e17..7f39e2f 100644 --- a/Sources/LatrKit/Library/SavedLibraryError.swift +++ b/Sources/LatrKit/Library/SavedLibraryError.swift @@ -5,4 +5,5 @@ public enum SavedLibraryError: Error, Sendable { case itemNotFound case conflict case invalidStoredRecord(uri: String) + case bookmarkNotFound } diff --git a/Sources/LatrKit/Models/BookmarkMetadata.swift b/Sources/LatrKit/Models/BookmarkMetadata.swift new file mode 100644 index 0000000..bae6d0e --- /dev/null +++ b/Sources/LatrKit/Models/BookmarkMetadata.swift @@ -0,0 +1,52 @@ +import Foundation + +public struct BookmarkMetadata: Codable, Sendable, Equatable { + public var type: String + public var bookmarkUri: String + public var subject: String + public var state: SavedItemState? + public var note: String? + public var lastOpenedAt: String? + public var legacyItemUris: [String]? + public var unknownFields: [String: JSONValue] + + enum CodingKeys: String, CodingKey, CaseIterable { + case type = "$type", bookmarkUri, subject, state, note, lastOpenedAt, legacyItemUris + } + + public init(bookmarkUri: String, subject: String, state: SavedItemState? = nil, note: String? = nil, lastOpenedAt: String? = nil, legacyItemUris: [String]? = nil, unknownFields: [String: JSONValue] = [:]) { + self.type = LexiconCollection.bookmarkMetadata.identifier + self.bookmarkUri = bookmarkUri + self.subject = subject + self.state = state + self.note = note + self.lastOpenedAt = lastOpenedAt + self.legacyItemUris = legacyItemUris + self.unknownFields = unknownFields + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = try c.decode(String.self, forKey: .type) + bookmarkUri = try c.decode(String.self, forKey: .bookmarkUri) + subject = try c.decode(String.self, forKey: .subject) + state = try c.decodeIfPresent(SavedItemState.self, forKey: .state) + note = try c.decodeIfPresent(String.self, forKey: .note) + lastOpenedAt = try c.decodeIfPresent(String.self, forKey: .lastOpenedAt) + legacyItemUris = try c.decodeIfPresent([String].self, forKey: .legacyItemUris) + let dynamic = try decoder.container(keyedBy: AnyCodingKey.self) + let known = Set(CodingKeys.allCases.map(\.rawValue)) + unknownFields = try dynamic.allKeys.reduce(into: [:]) { result, key in + if !known.contains(key.stringValue) { result[key.stringValue] = try dynamic.decode(JSONValue.self, forKey: key) } + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type); try c.encode(bookmarkUri, forKey: .bookmarkUri); try c.encode(subject, forKey: .subject) + try c.encodeIfPresent(state, forKey: .state); try c.encodeIfPresent(note, forKey: .note); try c.encodeIfPresent(lastOpenedAt, forKey: .lastOpenedAt) + try c.encodeIfPresent(legacyItemUris, forKey: .legacyItemUris) + var dynamic = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in unknownFields { try dynamic.encode(value, forKey: AnyCodingKey(stringValue: key)!) } + } +} diff --git a/Sources/LatrKit/Models/BookmarkMetadataSyncSummary.swift b/Sources/LatrKit/Models/BookmarkMetadataSyncSummary.swift new file mode 100644 index 0000000..659bf41 --- /dev/null +++ b/Sources/LatrKit/Models/BookmarkMetadataSyncSummary.swift @@ -0,0 +1,13 @@ +public struct BookmarkMetadataSyncSummary: Codable, Sendable, Equatable { + public var ok = true + public var scanned: Int + public var created = 0 + public var reused = 0 + public var skippedConflict = 0 + public var cursor: String? + + public init(scanned: Int = 0, cursor: String? = nil) { + self.scanned = scanned + self.cursor = cursor + } +} diff --git a/Sources/LatrKit/Models/BookmarkView.swift b/Sources/LatrKit/Models/BookmarkView.swift new file mode 100644 index 0000000..20b21bd --- /dev/null +++ b/Sources/LatrKit/Models/BookmarkView.swift @@ -0,0 +1,28 @@ +public struct BookmarkView: Codable, Sendable { + public let uri: String + public let cid: String + public let value: CommunityBookmark + public let metadataRecord: RepositoryRecord? + public let preview: OpenGraphPreview? + + public init( + record: RepositoryRecord, + metadataRecord: RepositoryRecord? = nil, + preview: OpenGraphPreview? = nil + ) { + uri = record.uri + cid = record.cid + value = record.value + self.metadataRecord = metadataRecord + self.preview = preview + } +} + +public struct BookmarkList: Codable, Sendable { + public let records: [BookmarkView] + public let cursor: String? + + public init(records: [BookmarkView], cursor: String?) { + self.records = records; self.cursor = cursor + } +} diff --git a/Sources/LatrKit/Models/CommunityBookmark.swift b/Sources/LatrKit/Models/CommunityBookmark.swift new file mode 100644 index 0000000..3ee947f --- /dev/null +++ b/Sources/LatrKit/Models/CommunityBookmark.swift @@ -0,0 +1,42 @@ +import Foundation + +public struct CommunityBookmark: Codable, Sendable, Equatable { + public var type: String + public var subject: String + public var createdAt: String + public var tags: [String]? + public var unknownFields: [String: JSONValue] + + enum CodingKeys: String, CodingKey, CaseIterable { case type = "$type", subject, createdAt, tags } + + public init(subject: String, createdAt: String, tags: [String]? = nil, unknownFields: [String: JSONValue] = [:]) { + self.type = LexiconCollection.bookmark.identifier + self.subject = subject + self.createdAt = createdAt + self.tags = tags + self.unknownFields = unknownFields + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = try c.decode(String.self, forKey: .type) + subject = try c.decode(String.self, forKey: .subject) + createdAt = try c.decode(String.self, forKey: .createdAt) + tags = try c.decodeIfPresent([String].self, forKey: .tags) + let dynamic = try decoder.container(keyedBy: AnyCodingKey.self) + let known = Set(CodingKeys.allCases.map(\.rawValue)) + unknownFields = try dynamic.allKeys.reduce(into: [:]) { result, key in + if !known.contains(key.stringValue) { result[key.stringValue] = try dynamic.decode(JSONValue.self, forKey: key) } + } + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(type, forKey: .type) + try c.encode(subject, forKey: .subject) + try c.encode(createdAt, forKey: .createdAt) + try c.encodeIfPresent(tags, forKey: .tags) + var dynamic = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in unknownFields { try dynamic.encode(value, forKey: AnyCodingKey(stringValue: key)!) } + } +} diff --git a/Sources/LatrKit/Models/LexiconCollection.swift b/Sources/LatrKit/Models/LexiconCollection.swift index cd53068..5e9815b 100644 --- a/Sources/LatrKit/Models/LexiconCollection.swift +++ b/Sources/LatrKit/Models/LexiconCollection.swift @@ -2,6 +2,8 @@ import Foundation /// ATProto collection identifiers for L@tr lexicons. public enum LexiconCollection: String, Sendable { + case bookmark = "community.lexicon.bookmarks.bookmark" + case bookmarkMetadata = "link.latr.bookmarks.metadata" case external = "link.latr.saved.external" case savedItem = "link.latr.saved.item" case legacyExternal = "com.latr.saved.external" @@ -11,7 +13,8 @@ public enum LexiconCollection: String, Sendable { public var isCurrent: Bool { switch self { - case .external, .savedItem: true + case .bookmark, .bookmarkMetadata: true + case .external, .savedItem: false case .legacyExternal, .legacySavedItem: false } } diff --git a/Sources/LatrKit/Models/OpenGraphPreview.swift b/Sources/LatrKit/Models/OpenGraphPreview.swift index a90e09a..90eb19f 100644 --- a/Sources/LatrKit/Models/OpenGraphPreview.swift +++ b/Sources/LatrKit/Models/OpenGraphPreview.swift @@ -1,7 +1,7 @@ import Foundation /// Parsed Open Graph fields merged onto saved records. -public struct OpenGraphPreview: Sendable, Equatable { +public struct OpenGraphPreview: Codable, Sendable, Equatable { public var title: String? public var description: String? public var image: String? diff --git a/Sources/LatrKit/RecordKey/TID.swift b/Sources/LatrKit/RecordKey/TID.swift new file mode 100644 index 0000000..af1f8b5 --- /dev/null +++ b/Sources/LatrKit/RecordKey/TID.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum TID { + private static let alphabet = Array("234567abcdefghijklmnopqrstuvwxyz") + + public static func now(clockID: UInt16 = UInt16.random(in: 0 ..< 1024)) -> String { + let micros = UInt64(Date().timeIntervalSince1970 * 1_000_000) + var value = (micros << 10) | UInt64(clockID & 0x03ff) + var result = Array(repeating: Character("2"), count: 13) + for index in stride(from: 12, through: 0, by: -1) { + result[index] = alphabet[Int(value & 31)] + value >>= 5 + } + return String(result) + } +} diff --git a/Sources/LatrKit/Repository/RepositoryClient.swift b/Sources/LatrKit/Repository/RepositoryClient.swift index 9447182..0816b11 100644 --- a/Sources/LatrKit/Repository/RepositoryClient.swift +++ b/Sources/LatrKit/Repository/RepositoryClient.swift @@ -1,3 +1,5 @@ +import Foundation + /// Abstraction over ATProto `com.atproto.repo.*` operations. public protocol RepositoryClient: Sendable { func listRecords( @@ -34,6 +36,26 @@ public protocol RepositoryClient: Sendable { withKey key: String, swapRecord: String? ) async throws + + func applyWrites(in repository: String, writes: [RepositoryWrite]) async throws +} + +public enum RepositoryWrite: Sendable, Equatable { + case create(collection: LexiconCollection, key: String, value: JSONValue) + case update(collection: LexiconCollection, key: String, value: JSONValue, swapRecord: String) + case delete(collection: LexiconCollection, key: String, swapRecord: String?) + + public static func creating(collection: LexiconCollection, key: String, value: some Encodable & Sendable) throws -> Self { + RepositoryWrite.create(collection: collection, key: key, value: try encodedJSONValue(value)) + } + + public static func updating(collection: LexiconCollection, key: String, value: some Encodable & Sendable, swapRecord: String) throws -> Self { + RepositoryWrite.update(collection: collection, key: key, value: try encodedJSONValue(value), swapRecord: swapRecord) + } + + private static func encodedJSONValue(_ value: some Encodable) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(value)) + } } public extension RepositoryClient { diff --git a/Sources/LatrKit/XRPC/LatrXRPCContracts.swift b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift index 0bde3a0..27bc79f 100644 --- a/Sources/LatrKit/XRPC/LatrXRPCContracts.swift +++ b/Sources/LatrKit/XRPC/LatrXRPCContracts.swift @@ -7,8 +7,20 @@ public struct LatrSaveURLInput: Codable, Sendable, Equatable { public let url: S public struct LatrSaveSubjectInput: Codable, Sendable, Equatable { public let subjectUri: String; public let linkedWebUrl: String?; public init(subjectUri: String, linkedWebUrl: String? = nil) { self.subjectUri = subjectUri; self.linkedWebUrl = linkedWebUrl } } public struct LatrSetStateInput: Codable, Sendable, Equatable { public let itemRkey: String; public let state: SavedItemState; public init(itemRkey: String, state: SavedItemState) { self.itemRkey = itemRkey; self.state = state } } public struct LatrDeleteItemInput: Codable, Sendable, Equatable { public let itemRkey: String; public init(itemRkey: String) { self.itemRkey = itemRkey } } -public struct LatrSimpleOK: Codable, Sendable, Equatable { public let ok: Bool } +public struct LatrSimpleOK: Codable, Sendable, Equatable { public let ok: Bool; public init(ok: Bool) { self.ok = ok } } public struct LatrSaveResult: Codable, Sendable, Equatable { public let ok: Bool; public let kind: String; public let subjectUri: String?; public let linkedWebUrl: String?; public let storage: String? } +public struct LatrListBookmarksParameters: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public struct LatrListBookmarksOutput: Codable, Sendable { public let bookmarks: [BookmarkView]; public let cursor: String?; public init(bookmarks: [BookmarkView], cursor: String?) { self.bookmarks = bookmarks; self.cursor = cursor } } +public struct LatrGetBookmarkOutput: Codable, Sendable { public let bookmark: BookmarkView?; public init(bookmark: BookmarkView?) { self.bookmark = bookmark } } +public struct LatrSaveBookmarkInput: Codable, Sendable, Equatable { public let subject: String; public let tags: [String]?; public init(subject: String, tags: [String]? = nil) { self.subject = subject; self.tags = tags } } +public struct LatrSyncBookmarkMetadataInput: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public struct LatrSetBookmarkStateInput: Codable, Sendable, Equatable { public let bookmarkUri: String; public let state: SavedItemState; public init(bookmarkUri: String, state: SavedItemState) { self.bookmarkUri = bookmarkUri; self.state = state } } +public struct LatrDeleteBookmarkInput: Codable, Sendable, Equatable { public let bookmarkUri: String; public init(bookmarkUri: String) { self.bookmarkUri = bookmarkUri } } +public struct LatrMigrateBookmarksInput: Codable, Sendable, Equatable { public let limit: Int?; public let cursor: String?; public init(limit: Int? = nil, cursor: String? = nil) { self.limit = limit; self.cursor = cursor } } +public struct LatrBookmarkMigrationResult: Codable, Sendable, Equatable { + public let ok: Bool; public let scanned: Int; public let created: Int; public let reused: Int; public let duplicates: Int + public let skippedConflict: Int; public let cached: Int; public let retired: Int; public let cursor: String? +} public enum LatrPayloadValidationError: Error, Sendable, Equatable { case invalidLimit; case invalidURL; case invalidATURI; case emptyRecordKey; case exceedsUTF8Limit(field: String, maximum: Int) } public enum LatrPayloadValidator { @@ -17,13 +29,49 @@ public enum LatrPayloadValidator { guard value.utf8.count <= 8192 else { throw LatrPayloadValidationError.exceedsUTF8Limit(field: field, maximum: 8192) } guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { throw LatrPayloadValidationError.invalidURL } } - public static func validateATURI(_ value: String) throws { guard value.utf8.count <= 8192, value.hasPrefix("at://"), value.split(separator: "/").count >= 5 else { throw LatrPayloadValidationError.invalidATURI } } + public static func validateATURI(_ value: String) throws { guard value.utf8.count <= 8192, value.hasPrefix("at://"), value.split(separator: "/").count >= 4 else { throw LatrPayloadValidationError.invalidATURI } } public static func validateRecordKey(_ value: String) throws { guard !value.isEmpty else { throw LatrPayloadValidationError.emptyRecordKey } } + public static func validateSubject(_ value: String) throws { + guard value.utf8.count <= 8192 else { throw LatrPayloadValidationError.invalidURL } + if value.hasPrefix("at://") { try validateATURI(value); return } + guard let scheme = URLComponents(string: value)?.scheme?.lowercased(), ["http", "https"].contains(scheme) else { throw LatrPayloadValidationError.invalidURL } + } } public struct LatrXRPCClient: Sendable { public let transport: any LatrXRPCTransport public init(transport: any LatrXRPCTransport) { self.transport = transport } + public func listBookmarks(_ parameters: LatrListBookmarksParameters = .init()) async throws -> LatrListBookmarksOutput { + if let limit = parameters.limit, !(1 ... 100).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + var query: [URLQueryItem] = [] + if let limit = parameters.limit { query.append(URLQueryItem(name: "limit", value: String(limit))) } + if let cursor = parameters.cursor { query.append(URLQueryItem(name: "cursor", value: cursor)) } + return try JSONDecoder().decode(LatrListBookmarksOutput.self, from: try await transport.send(method: .listBookmarks, parameters: query, body: nil)) + } + public func getBookmark(subject: String) async throws -> LatrGetBookmarkOutput { + try LatrPayloadValidator.validateSubject(subject) + return try JSONDecoder().decode(LatrGetBookmarkOutput.self, from: try await transport.send(method: .getBookmark, parameters: [URLQueryItem(name: "subject", value: subject)], body: nil)) + } + public func saveBookmark(_ input: LatrSaveBookmarkInput) async throws -> BookmarkView { + try LatrPayloadValidator.validateSubject(input.subject) + return try await procedure(.saveBookmark, input, as: BookmarkView.self) + } + public func syncBookmarkMetadata(_ input: LatrSyncBookmarkMetadataInput = .init()) async throws -> BookmarkMetadataSyncSummary { + if let limit = input.limit, !(1 ... 100).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + return try await procedure(.syncBookmarkMetadata, input, as: BookmarkMetadataSyncSummary.self) + } + public func setBookmarkState(_ input: LatrSetBookmarkStateInput) async throws -> LatrSimpleOK { + try LatrPayloadValidator.validateATURI(input.bookmarkUri) + return try await procedure(.setBookmarkState, input, as: LatrSimpleOK.self) + } + public func deleteBookmark(_ input: LatrDeleteBookmarkInput) async throws -> LatrSimpleOK { + try LatrPayloadValidator.validateATURI(input.bookmarkUri) + return try await procedure(.deleteBookmark, input, as: LatrSimpleOK.self) + } + public func migrateBookmarks(_ input: LatrMigrateBookmarksInput = .init()) async throws -> LatrBookmarkMigrationResult { + if let limit = input.limit, !(1 ... 100).contains(limit) { throw LatrPayloadValidationError.invalidLimit } + return try await procedure(.migrateBookmarks, input, as: LatrBookmarkMigrationResult.self) + } public func listItems(_ parameters: LatrListItemsParameters) async throws -> LatrListItemsOutput { try LatrPayloadValidator.validate(parameters) var query = [URLQueryItem(name: "limit", value: String(parameters.limit))] diff --git a/Sources/LatrKit/XRPC/LatrXRPCMethod.swift b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift index 1afd3c3..2715e8a 100644 --- a/Sources/LatrKit/XRPC/LatrXRPCMethod.swift +++ b/Sources/LatrKit/XRPC/LatrXRPCMethod.swift @@ -7,6 +7,14 @@ public struct LatrXRPCMethod: Hashable, Sendable { public let requiresApplicationCredential: Bool public var verb: String { kind == .query ? "GET" : "POST" } + public static let listBookmarks = Self("link.latr.bookmarks.listBookmarks", .query) + public static let getBookmark = Self("link.latr.bookmarks.getBookmark", .query) + public static let saveBookmark = Self("link.latr.bookmarks.saveBookmark", .procedure) + public static let syncBookmarkMetadata = Self("link.latr.bookmarks.syncMetadata", .procedure) + public static let setBookmarkState = Self("link.latr.bookmarks.setState", .procedure) + public static let deleteBookmark = Self("link.latr.bookmarks.deleteBookmark", .procedure) + public static let migrateBookmarks = Self("link.latr.bookmarks.migrateLegacy", .procedure) + public static let listItems = Self("link.latr.saved.listItems", .query) public static let getItem = Self("link.latr.saved.getItem", .query) public static let saveURL = Self("link.latr.saved.saveUrl", .procedure) @@ -24,7 +32,7 @@ public struct LatrXRPCMethod: Hashable, Sendable { public static let createKey = Self("link.latr.developer.createKey", .procedure, false) public static let revokeKey = Self("link.latr.developer.revokeKey", .procedure, false) public static let getUsage = Self("link.latr.developer.getUsage", .query, false) - public static let all: [Self] = [.listItems, .getItem, .saveURL, .saveSubject, .setState, .deleteItem, .migrateLegacy, .getOpenGraph, .resolveURL, .authProbe, .listClients, .createClient, .deleteClient, .listKeys, .createKey, .revokeKey, .getUsage] + public static let all: [Self] = [.listBookmarks, .getBookmark, .saveBookmark, .syncBookmarkMetadata, .setBookmarkState, .deleteBookmark, .migrateBookmarks, .listItems, .getItem, .saveURL, .saveSubject, .setState, .deleteItem, .migrateLegacy, .getOpenGraph, .resolveURL, .authProbe, .listClients, .createClient, .deleteClient, .listKeys, .createKey, .revokeKey, .getUsage] private init(_ nsid: String, _ kind: Kind, _ requiresApplicationCredential: Bool = true) { self.nsid = nsid; self.kind = kind; self.requiresApplicationCredential = requiresApplicationCredential diff --git a/Tests/LatrKitTests/BookmarkLibraryTests.swift b/Tests/LatrKitTests/BookmarkLibraryTests.swift new file mode 100644 index 0000000..f2eee80 --- /dev/null +++ b/Tests/LatrKitTests/BookmarkLibraryTests.swift @@ -0,0 +1,230 @@ +import LatrKit +import XCTest + +final class BookmarkLibraryTests: XCTestCase { + private let did = "did:plc:testviewer" + + func testTIDUsesCanonicalLengthAndAlphabet() { + let tid = TID.now(clockID: 1) + XCTAssertEqual(tid.count, 13) + XCTAssertTrue(tid.allSatisfy { "234567abcdefghijklmnopqrstuvwxyz".contains($0) }) + } + + func testSavePreservesExactHTTPSubjectAndIsIdempotent() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let subject = "https://Example.com/article?utm_source=encountered#section" + let first = try await library.saveBookmark(subject: subject, tags: ["news", "news"]) + let second = try await library.saveBookmark(subject: subject, tags: ["later"]) + + XCTAssertEqual(first.uri, second.uri) + XCTAssertEqual(second.value.subject, subject) + XCTAssertEqual(second.value.tags, ["later", "news"]) + XCTAssertEqual(second.metadataRecord?.value.state, .unread) + XCTAssertEqual(repository.snapshotKeys().filter { $0.hasPrefix("\(LexiconCollection.bookmark.identifier):") }.count, 1) + } + + func testDirectATURISubjectAndStateSidecar() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let subject = "at://did:plc:author/app.bsky.feed.post/3abc" + let saved = try await library.saveBookmark(subject: subject) + try await library.setState(ofBookmarkURI: saved.uri, to: .archived) + let updated = try await library.bookmark(subject: subject) + XCTAssertEqual(updated?.value.subject, subject) + XCTAssertEqual(updated?.metadataRecord?.value.state, .archived) + } + + func testSyncCreatesUnreadMetadataForExternalHTTPAndATBookmarks() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let subjects = [ + "https://Example.com/article?utm_source=encountered#section", + "at://did:plc:author/app.bsky.feed.post/3abc", + ] + for (index, subject) in subjects.enumerated() { + _ = try await repository.createRecord( + in: did, + collection: .bookmark, + withKey: "external-\(index)", + value: CommunityBookmark(subject: subject, createdAt: "2026-01-0\(index + 1)T00:00:00Z") + ) + } + + let summary = try await library.syncBookmarkMetadata() + + XCTAssertEqual(summary.scanned, 2) + XCTAssertEqual(summary.created, 2) + for (index, subject) in subjects.enumerated() { + let metadata: RepositoryRecord? = try await repository.record( + in: did, + collection: .bookmarkMetadata, + withKey: "external-\(index)" + ) + XCTAssertEqual(metadata?.value.bookmarkUri, "at://\(did)/\(LexiconCollection.bookmark.identifier)/external-\(index)") + XCTAssertEqual(metadata?.value.subject, subject) + XCTAssertEqual(metadata?.value.state, .unread) + } + } + + func testSyncPreservesValidMetadataAndSkipsMismatchedSidecar() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let validURI = "at://\(did)/\(LexiconCollection.bookmark.identifier)/valid" + let conflictURI = "at://\(did)/\(LexiconCollection.bookmark.identifier)/conflict" + _ = try await repository.createRecord(in: did, collection: .bookmark, withKey: "valid", value: CommunityBookmark( + subject: "https://example.com/valid", createdAt: "2026-01-01T00:00:00Z" + )) + _ = try await repository.createRecord(in: did, collection: .bookmarkMetadata, withKey: "valid", value: BookmarkMetadata( + bookmarkUri: validURI, + subject: "https://example.com/valid", + state: .archived, + unknownFields: ["future": .string("preserve")] + )) + _ = try await repository.createRecord(in: did, collection: .bookmark, withKey: "conflict", value: CommunityBookmark( + subject: "https://example.com/current", createdAt: "2026-01-02T00:00:00Z" + )) + _ = try await repository.createRecord(in: did, collection: .bookmarkMetadata, withKey: "conflict", value: BookmarkMetadata( + bookmarkUri: conflictURI, + subject: "https://example.com/stale", + state: .archived + )) + + let summary = try await library.syncBookmarkMetadata() + let list = try await library.bookmarks() + + XCTAssertEqual(summary.created, 0) + XCTAssertEqual(summary.reused, 1) + XCTAssertEqual(summary.skippedConflict, 1) + XCTAssertEqual(list.records.first { $0.uri == validURI }?.metadataRecord?.value.unknownFields["future"], .string("preserve")) + XCTAssertNil(list.records.first { $0.uri == conflictURI }?.metadataRecord) + } + + func testSyncIsIdempotentAndCursorPaged() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + for index in 0 ..< 3 { + _ = try await repository.createRecord(in: did, collection: .bookmark, withKey: "page-\(index)", value: CommunityBookmark( + subject: "https://example.com/\(index)", createdAt: "2026-01-0\(index + 1)T00:00:00Z" + )) + } + + let first = try await library.syncBookmarkMetadata(limit: 2) + let second = try await library.syncBookmarkMetadata(limit: 2, startingAfter: first.cursor) + let retry = try await library.syncBookmarkMetadata(limit: 2) + + XCTAssertEqual(first.scanned, 2) + XCTAssertEqual(first.created, 2) + XCTAssertNotNil(first.cursor) + XCTAssertEqual(second.scanned, 1) + XCTAssertEqual(second.created, 1) + XCTAssertNil(second.cursor) + XCTAssertEqual(retry.created, 0) + XCTAssertEqual(retry.reused, 2) + } + + func testSyncAtomicCreateConflictSucceedsOnRetry() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let key = "racing" + let subject = "https://example.com/racing" + let uri = "at://\(did)/\(LexiconCollection.bookmark.identifier)/\(key)" + let repositoryDID = did + _ = try await repository.createRecord(in: did, collection: .bookmark, withKey: key, value: CommunityBookmark( + subject: subject, createdAt: "2026-01-01T00:00:00Z" + )) + repository.beforeNextApplyWrites { _ in + _ = try await repository.createRecord( + in: repositoryDID, + collection: .bookmarkMetadata, + withKey: key, + value: BookmarkMetadata(bookmarkUri: uri, subject: subject, state: .unread) + ) + } + + do { + _ = try await library.syncBookmarkMetadata() + XCTFail("Expected an atomic create conflict") + } catch RepositoryClientError.conflict {} + + let retry = try await library.syncBookmarkMetadata() + XCTAssertEqual(retry.created, 0) + XCTAssertEqual(retry.reused, 1) + } + + func testMigrationFlattensWrapperPreservesStateAndIsRetrySafe() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let externalKey = "external" + let wrapperURI = "at://\(did)/\(LexiconCollection.external.identifier)/\(externalKey)" + _ = try await repository.createRecord(in: did, collection: .external, withKey: externalKey, value: ExternalSave( + url: "https://example.com/original?ref=encountered", + normalizedUrl: "https://example.com/original", + fingerprint: "abc", + createdAt: "2026-01-01T00:00:00Z", + title: "Derived title" + )) + _ = try await repository.createRecord(in: did, collection: .savedItem, withKey: "item", value: SavedItem( + subjectUri: wrapperURI, + savedAt: "2026-01-02T00:00:00Z", + state: .archived, + tags: ["news"], + note: "Keep me" + )) + + let first = try await library.migrateBookmarks() + let migrated = try await library.bookmark(subject: "https://example.com/original?ref=encountered") + let second = try await library.migrateBookmarks() + + XCTAssertEqual(first.created, 1) + XCTAssertEqual(first.retired, 2) + XCTAssertEqual(migrated?.value.createdAt, "2026-01-02T00:00:00Z") + XCTAssertEqual(migrated?.value.tags, ["news"]) + XCTAssertEqual(migrated?.metadataRecord?.value.state, .archived) + XCTAssertEqual(migrated?.metadataRecord?.value.note, "Keep me") + XCTAssertEqual(second.scanned, 0) + } + + func testMigrationLeavesConflictingDuplicateNotesUntouched() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + let subject = "https://example.com/conflict" + _ = try await repository.createRecord(in: did, collection: .savedItem, withKey: "a", value: SavedItem( + subjectUri: "at://did:plc:author/site.standard.document/a", + savedAt: "2026-01-01T00:00:00Z", + note: "first", + linkedWebUrl: subject + )) + _ = try await repository.createRecord(in: did, collection: .legacySavedItem, withKey: "b", value: SavedItem( + subjectUri: "at://did:plc:author/site.standard.document/a", + savedAt: "2026-01-02T00:00:00Z", + note: "second", + linkedWebUrl: subject + )) + + let summary = try await library.migrateBookmarks() + + XCTAssertEqual(summary.skippedConflict, 2) + let migrated = try await library.bookmark(subject: subject) + XCTAssertNil(migrated) + XCTAssertTrue(repository.hasRecord(collection: .savedItem, key: "a")) + XCTAssertTrue(repository.hasRecord(collection: .legacySavedItem, key: "b")) + } + + func testMigrationLeavesUnknownLegacyFieldsUntouched() async throws { + let repository = InMemoryRepository() + let library = SavedLibrary(repository: repository, repositoryDID: did) + _ = try await repository.createRecord(in: did, collection: .savedItem, withKey: "unknown", value: SavedItem( + subjectUri: "https://example.com/unknown", + savedAt: "2026-01-01T00:00:00Z", + unknownFields: ["future": .string("preserve")] + )) + + let summary = try await library.migrateBookmarks() + + XCTAssertEqual(summary.skippedConflict, 1) + XCTAssertTrue(repository.hasRecord(collection: .savedItem, key: "unknown")) + let migrated = try await library.bookmark(subject: "https://example.com/unknown") + XCTAssertNil(migrated) + } +} diff --git a/Tests/LatrKitTests/InMemoryRepository.swift b/Tests/LatrKitTests/InMemoryRepository.swift index 4e1c919..6fb26be 100644 --- a/Tests/LatrKitTests/InMemoryRepository.swift +++ b/Tests/LatrKitTests/InMemoryRepository.swift @@ -3,6 +3,7 @@ import LatrKit final class InMemoryRepository: RepositoryClient, @unchecked Sendable { private var store: [String: (uri: String, cid: String, json: Data)] = [:] + private var beforeNextApplyWrites: (@Sendable ([RepositoryWrite]) async throws -> Void)? func snapshotKeys() -> [String] { Array(store.keys) } @@ -23,6 +24,7 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { let decoded = try JSONDecoder().decode(Value.self, from: entry.json) return RepositoryRecord(uri: entry.uri, cid: entry.cid, value: decoded) } + .sorted { $0.uri < $1.uri } let start = cursor.flatMap { Int($0) } ?? 0 let pageLimit = limit ?? 100 @@ -75,7 +77,38 @@ final class InMemoryRepository: RepositoryClient, @unchecked Sendable { store.removeValue(forKey: storeKey(collection: collection, key: key)) } + func applyWrites(in repository: String, writes: [RepositoryWrite]) async throws { + if let hook = beforeNextApplyWrites { + beforeNextApplyWrites = nil + try await hook(writes) + } + var next = store + for write in writes { + switch write { + case let .create(collection, key, value): + let storeKey = storeKey(collection: collection, key: key) + guard next[storeKey] == nil else { throw RepositoryClientError.conflict } + let uri = "at://\(repository)/\(collection.identifier)/\(key)" + next[storeKey] = (uri, "bafytest", try JSONEncoder().encode(value)) + case let .update(collection, key, value, swapRecord): + let storeKey = storeKey(collection: collection, key: key) + guard next[storeKey]?.cid == swapRecord else { throw RepositoryClientError.conflict } + let uri = "at://\(repository)/\(collection.identifier)/\(key)" + next[storeKey] = (uri, "bafyupdated", try JSONEncoder().encode(value)) + case let .delete(collection, key, swapRecord): + let storeKey = storeKey(collection: collection, key: key) + if let swapRecord, next[storeKey]?.cid != swapRecord { throw RepositoryClientError.conflict } + next.removeValue(forKey: storeKey) + } + } + store = next + } + func hasRecord(collection: LexiconCollection, key: String) -> Bool { store[storeKey(collection: collection, key: key)] != nil } + + func beforeNextApplyWrites(_ hook: @escaping @Sendable ([RepositoryWrite]) async throws -> Void) { + beforeNextApplyWrites = hook + } } diff --git a/Tests/LatrKitTests/SubjectPreviewResolverTests.swift b/Tests/LatrKitTests/SubjectPreviewResolverTests.swift index d328e5f..5c76358 100644 --- a/Tests/LatrKitTests/SubjectPreviewResolverTests.swift +++ b/Tests/LatrKitTests/SubjectPreviewResolverTests.swift @@ -144,4 +144,8 @@ private struct MockRepository: RepositoryClient { withKey key: String, swapRecord: String? ) async throws {} + + func applyWrites(in repository: String, writes: [RepositoryWrite]) async throws { + throw RepositoryClientError.invalidStoredRecord(uri: "mock does not support writes") + } } diff --git a/Tests/LatrKitTests/XRPCContractTests.swift b/Tests/LatrKitTests/XRPCContractTests.swift index fa3372f..2dceb84 100644 --- a/Tests/LatrKitTests/XRPCContractTests.swift +++ b/Tests/LatrKitTests/XRPCContractTests.swift @@ -3,12 +3,28 @@ import Testing @testable import LatrKit @Test func xrpcDescriptorsHaveStableVerbsAndCredentialPolicy() { - #expect(LatrXRPCMethod.all.count == 17) + #expect(LatrXRPCMethod.all.count == 24) + #expect(LatrXRPCMethod.listBookmarks.verb == "GET") + #expect(LatrXRPCMethod.saveBookmark.nsid == "link.latr.bookmarks.saveBookmark") + #expect(LatrXRPCMethod.syncBookmarkMetadata.nsid == "link.latr.bookmarks.syncMetadata") + #expect(LatrXRPCMethod.syncBookmarkMetadata.verb == "POST") #expect(LatrXRPCMethod.listItems.verb == "GET") #expect(LatrXRPCMethod.saveURL.verb == "POST") #expect(!LatrXRPCMethod.listClients.requiresApplicationCredential) } +@Test func communityBookmarkAndMetadataPreserveUnknownFields() throws { + let bookmarkData = Data(#"{"$type":"community.lexicon.bookmarks.bookmark","subject":"https://example.com","createdAt":"2026-08-13T00:00:00Z","future":true}"#.utf8) + let metadataData = Data(#"{"$type":"link.latr.bookmarks.metadata","bookmarkUri":"at://did:plc:test/community.lexicon.bookmarks.bookmark/3abc","subject":"https://example.com","state":"unread","future":{"version":2}}"#.utf8) + let bookmark = try JSONDecoder().decode(CommunityBookmark.self, from: bookmarkData) + var metadata = try JSONDecoder().decode(BookmarkMetadata.self, from: metadataData) + metadata.state = .archived + let bookmarkObject = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(bookmark)) as? [String: Any]) + let metadataObject = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(metadata)) as? [String: Any]) + #expect(bookmarkObject["future"] as? Bool == true) + #expect((metadataObject["future"] as? [String: Int])?["version"] == 2) +} + @Test func recordsPreserveUnknownFieldsAcrossMutationRoundTrip() throws { let data = Data(#"{"$type":"link.latr.saved.item","subjectUri":"at://did:plc:test/app.bsky.feed.post/abc","savedAt":"2026-08-13T00:00:00Z","future":{"enabled":true}}"#.utf8) var item = try JSONDecoder().decode(SavedItem.self, from: data) @@ -18,6 +34,18 @@ import Testing #expect((object["future"] as? [String: Bool])?["enabled"] == true) } +@Test func bookmarkViewDecodesServiceDerivedPreview() throws { + let data = Data(#"{"uri":"at://did:plc:test/community.lexicon.bookmarks.bookmark/3abc","cid":"bafybookmark","value":{"$type":"community.lexicon.bookmarks.bookmark","subject":"https://example.com/story","createdAt":"2026-08-13T00:00:00Z"},"preview":{"title":"A story","description":"Summary","image":"https://example.com/og.png","siteName":"Example","author":"Ada"}}"#.utf8) + + let view = try JSONDecoder().decode(BookmarkView.self, from: data) + + #expect(view.preview?.title == "A story") + #expect(view.preview?.description == "Summary") + #expect(view.preview?.image == "https://example.com/og.png") + #expect(view.preview?.siteName == "Example") + #expect(view.preview?.author == "Ada") +} + @Test func validationCountsUTF8Bytes() { let oversized = String(repeating: "😀", count: 2_049) #expect(throws: LatrPayloadValidationError.exceedsUTF8Limit(field: "url", maximum: 8192)) {