rastrillo / native Public

WIP: implementer stopped mid-task (weekly model limit); untested

Saved as-is by the coordinating session so the work isn't lost. The next
implementer continues from here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 68a51d71a6ba80660c427d41b4dbf414663f86e8 parent 14ee567
17 files changed, +1421 −10
  • Package.swift +3 −3
  • Sources/RastrilloApple/NativeOfflineBanner.swift +15 −7
  • Sources/RastrilloApple/NativeSealedStore.swift +68 −0
  • Sources/RastrilloApple/NativeSyncStatusView.swift +43 −0
  • Sources/RastrilloApple/NativeSyncStrings.swift +40 −0
  • Sources/RastrilloApple/OutboxDrainer.swift +74 −0
  • Sources/RastrilloApple/Resources/en.lproj/Localizable.strings +1 −0
  • Sources/RastrilloNative/BlobStore.swift +38 −0
  • Sources/RastrilloNative/Outbox.swift +119 −0
  • Sources/RastrilloNative/SyncStatus.swift +38 −0
  • Sources/RastrilloTestSupport/FaultingBlobStore.swift +44 −0
  • Tests/RastrilloAppleTests/OutboxDrainerTests.swift +272 −0
  • Tests/RastrilloAppleTests/SealedStoreTests.swift +175 −0
  • Tests/RastrilloAppleTests/SyncStringsTests.swift +63 −0
  • Tests/RastrilloNativeTests/OutboxTests.swift +309 −0
  • Tests/RastrilloNativeTests/SyncStatusTests.swift +71 −0
  • Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift +48 −0

CI failed — run details

diff --git a/Package.swift b/Package.swift
index 974a178..ba69758 100644
--- a/Package.swift
+++ b/Package.swift
@@ -12,12 +12,12 @@ let package = Package(
dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")],
targets: [
.target(name: "RastrilloNative"),
- .target(name: "RastrilloApple", dependencies: ["RastrilloNative"]),
- .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple"]),
+ .target(name: "RastrilloApple", dependencies: ["RastrilloNative"], resources: [.process("Resources")]),
+ .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple", "RastrilloNative"]),
.target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]),
.testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]),
.testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative"]),
- .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]),
+ .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative", "RastrilloTestSupport"]),
.testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]),
]
)
diff --git a/Sources/RastrilloApple/NativeOfflineBanner.swift b/Sources/RastrilloApple/NativeOfflineBanner.swift
index e4bb586..080c4ea 100644
--- a/Sources/RastrilloApple/NativeOfflineBanner.swift
+++ b/Sources/RastrilloApple/NativeOfflineBanner.swift
@@ -1,17 +1,25 @@
+import RastrilloNative
import SwiftUI
-/// A thin inset bar shown while `origin` is offline. It says only what is
-/// true without a durable outbox: pass the durable wording ("Changes are saved
-/// on this device…") once the app's edits survive a relaunch. Entering the
-/// offline state is announced to assistive technologies; leaving it is not.
+/// A thin inset bar shown while `origin` is offline. Entering the offline
+/// state is announced to assistive technologies; leaving it is not.
+///
+/// Pass `summary` once the app's edits are kept in an `Outbox`: the banner
+/// then says they are saved on this device, which is only true from then on.
public struct NativeOfflineBanner: View {
private let connectivity: NativeConnectivity
private let origin: URL
private let message: String
- @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL,
- message: String = "You’re offline. Waiting for a connection.") {
- self.connectivity = connectivity ?? .shared; self.origin = origin; self.message = message
+ @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, message: String? = nil) {
+ self.connectivity = connectivity ?? .shared; self.origin = origin
+ self.message = message ?? NativeSyncStrings.kit.offlineWaiting
+ }
+
+ /// The durable wording, for an app whose changes go through an outbox.
+ @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, summary: OutboxSummary,
+ strings: NativeSyncStrings = .kit) {
+ self.init(connectivity: connectivity, origin: origin, message: strings.offlineDurable)
}
public var body: some View {
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift
new file mode 100644
index 0000000..3b75ec2
--- /dev/null
+++ b/Sources/RastrilloApple/NativeSealedStore.swift
@@ -0,0 +1,68 @@
+import CryptoKit
+import Foundation
+import RastrilloNative
+import Security
+
+/// Where an account's store key lives. `KeychainKeyProvider` is the real one;
+/// tests script every Keychain status with `ScriptedKeyProvider`.
+///
+/// It answers in raw Keychain statuses on purpose: telling *unavailable*
+/// from *lost* is the store's job, and it must see the status to do it.
+public protocol KeyProvider: Sendable {
+ /// `errSecSuccess` with the key, or the status the Keychain gave.
+ func copyKey() -> (OSStatus, SymmetricKey?)
+ func addKey(_ key: SymmetricKey) -> OSStatus
+ func deleteKey() -> OSStatus
+}
+
+/// One AES-256 key per account, a generic password under service
+/// `<bundle id>.store` and account `store:<account id>|<origin>`, readable
+/// only while the device is unlocked, on this device, and never synchronised.
+public struct KeychainKeyProvider: KeyProvider {
+ public let service: String
+ public let account: String
+
+ public init(accountID: String, origin: URL, bundleID: String = Bundle.main.bundleIdentifier ?? "oficina.native") {
+ service = bundleID + ".store"
+ account = "store:\(accountID)|\(origin.absoluteString)"
+ }
+
+ private var query: [String: Any] {
+ [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
+ kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false]
+ }
+
+ public func copyKey() -> (OSStatus, SymmetricKey?) {
+ var q = query
+ q[kSecReturnData as String] = true
+ q[kSecMatchLimit as String] = kSecMatchLimitOne
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(q as CFDictionary, &result)
+ guard status == errSecSuccess else { return (status, nil) }
+ guard let data = result as? Data, data.count == 32 else { return (errSecDecode, nil) }
+ return (status, SymmetricKey(data: data))
+ }
+
+ public func addKey(_ key: SymmetricKey) -> OSStatus {
+ var q = query
+ q[kSecValueData as String] = key.withUnsafeBytes { Data($0) }
+ q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ return SecItemAdd(q as CFDictionary, nil)
+ }
+
+ public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) }
+}
+
+/// STUB for the red run.
+public final class NativeSealedStore: BlobStore, @unchecked Sendable {
+ public static let formatVersion = 1
+ public let root: URL
+ public let accountID: String
+ public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID }
+ public func write(_ data: Data, to path: String) throws {}
+ public func read(_ path: String) throws -> Data? { nil }
+ public func list(_ directory: String) throws -> [String] { [] }
+ public func remove(_ path: String) throws {}
+ public func wipe() throws {}
+ static let writeOptions: Data.WritingOptions = []
+}
diff --git a/Sources/RastrilloApple/NativeSyncStatusView.swift b/Sources/RastrilloApple/NativeSyncStatusView.swift
new file mode 100644
index 0000000..4c47f71
--- /dev/null
+++ b/Sources/RastrilloApple/NativeSyncStatusView.swift
@@ -0,0 +1,43 @@
+import RastrilloNative
+import SwiftUI
+
+/// A compact save status for a toolbar or title bar. Silent when saved, but
+/// the accessibility element stays present while silent, so the first
+/// failure is announced from a region that already exists (estilo
+/// savestatus). Entering offline, needs-sign-in and failed is announced; a
+/// saving flicker never is.
+public struct NativeSyncStatusView: View {
+ private let status: SyncStatus
+ private let strings: NativeSyncStrings
+
+ public init(status: SyncStatus, strings: NativeSyncStrings = .kit) {
+ self.status = status
+ self.strings = strings
+ }
+
+ public var body: some View {
+ let text = strings.text(for: status) ?? ""
+ Text(text)
+ .font(.callout)
+ .fontWeight(status.saveState == .failed ? .semibold : .regular)
+ .foregroundStyle(status.saveState == .failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary))
+ .lineLimit(2)
+ .frame(minWidth: 8)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(text)
+ .accessibilityAddTraits(.updatesFrequently)
+ .onChange(of: status) { old, new in
+ guard new.isAnnounced, !Self.sameKind(old, new), let text = strings.text(for: new) else { return }
+ AccessibilityNotification.Announcement(text).post()
+ }
+ }
+
+ /// A count changing inside the same state is not news.
+ static func sameKind(_ a: SyncStatus, _ b: SyncStatus) -> Bool {
+ switch (a, b) {
+ case (.offline, .offline), (.needsSignIn, .needsSignIn), (.retrying, .retrying): return true
+ case (.failed(_, let x), .failed(_, let y)): return x == y
+ default: return a == b
+ }
+ }
+}
diff --git a/Sources/RastrilloApple/NativeSyncStrings.swift b/Sources/RastrilloApple/NativeSyncStrings.swift
new file mode 100644
index 0000000..2d81af4
--- /dev/null
+++ b/Sources/RastrilloApple/NativeSyncStrings.swift
@@ -0,0 +1,40 @@
+import Foundation
+import RastrilloNative
+
+/// The words the kit's sync views say. `.kit` reads the package's own
+/// catalog, in the same twelve languages as the apps; an app overrides any
+/// of them, for example to count cells instead of documents, by changing a
+/// copy.
+public struct NativeSyncStrings: Sendable {
+ public var saving: String
+ public var retrying: String
+ /// "Offline · N pending".
+ public var offline: @Sendable (Int) -> String
+ /// "Sign in to send N pending".
+ public var needsSignIn: @Sendable (Int) -> String
+ public var signIn: String
+ /// A held entry's words are the app's; this is the fallback.
+ public var failed: @Sendable (SyncFailure) -> String
+ /// The banner while the path or server is down and edits are not durable.
+ public var offlineWaiting: String
+ /// The banner once edits are kept on this device.
+ public var offlineDurable: String
+ /// Reported once after a lost key or corrupt entry removed unsent changes.
+ public var unsentLost: String
+
+ public init(saving: String, retrying: String, offline: @escaping @Sendable (Int) -> String,
+ needsSignIn: @escaping @Sendable (Int) -> String, signIn: String,
+ failed: @escaping @Sendable (SyncFailure) -> String, offlineWaiting: String,
+ offlineDurable: String, unsentLost: String) {
+ self.saving = saving; self.retrying = retrying; self.offline = offline; self.needsSignIn = needsSignIn
+ self.signIn = signIn; self.failed = failed; self.offlineWaiting = offlineWaiting
+ self.offlineDurable = offlineDurable; self.unsentLost = unsentLost
+ }
+
+ public static var kit: NativeSyncStrings { kit(localization: "en") }
+ public static func kit(localization: String) -> NativeSyncStrings {
+ NativeSyncStrings(saving: "", retrying: "", offline: { _ in "" }, needsSignIn: { _ in "" }, signIn: "", failed: { _ in "" },
+ offlineWaiting: "", offlineDurable: "", unsentLost: "")
+ }
+ public func text(for status: SyncStatus) -> String? { nil }
+}
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift
new file mode 100644
index 0000000..5350a5b
--- /dev/null
+++ b/Sources/RastrilloApple/OutboxDrainer.swift
@@ -0,0 +1,74 @@
+import Foundation
+import Observation
+import RastrilloNative
+
+/// Sends one account's outbox. The app supplies `send`, which turns a batch
+/// of one lane into requests; the drainer decides when to call it, and what
+/// an error it throws means, so every app reacts the same way:
+///
+/// | Thrown | Reaction |
+/// |---|---|
+/// | `.offline`, path down | stop; wait for a connectivity change, with no timer |
+/// | `.offline`, path up (a timeout, a lost response) | back off, retry the same batch |
+/// | `.unauthorized` | session ended: stop every lane; never hold or drop |
+/// | `.rateLimited(after)` | back off at least `after` |
+/// | `.server`, `.transport`, `.invalidResponse` | back off, retry the same batch |
+/// | `BlobStoreError.unavailable` | store locked: wait for the next change (foreground, unlock) |
+/// | `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | hold the batch rather than spin |
+///
+/// A failure to acknowledge or hold after a send is a `.retry`: the server
+/// may already have the batch, and each app's server-side idempotency makes
+/// sending it again harmless.
+///
+/// At most one batch is in flight, across all lanes: the simplest correct
+/// ordering against one server.
+@MainActor @Observable public final class OutboxDrainer {
+ public enum Disposition: Sendable, Equatable {
+ case sent
+ case retry
+ case hold(Hold)
+ }
+
+ /// Where the drainer is. `SyncStatus` reads it.
+ public enum Phase: Sendable, Equatable {
+ case idle
+ case sending
+ case waitingForNetwork
+ case backingOff
+ case sessionEnded
+ case storeUnavailable
+ }
+
+ /// What one `drain()` ended on.
+ public enum Outcome: Sendable, Equatable {
+ /// Nothing left that can be sent.
+ case idle
+ case waitingForNetwork
+ /// Retry after this delay (a connectivity change may cut it short).
+ case backingOff(TimeInterval)
+ /// The server's Retry-After: nothing but "Try again" crosses it.
+ case rateLimited(TimeInterval)
+ case sessionEnded
+ case storeUnavailable
+ }
+
+ /// Returns a disposition per entry. An entry it leaves out was not sent
+ /// this time (for example, a merge that had to fall back to the first
+ /// entry): it goes back to sendable with no backoff.
+ public typealias Send = @MainActor (_ lane: String, _ entries: [OutboxEntry], _ transport: NativeTransport) async throws -> [UUID: Disposition]
+
+ public let outbox: Outbox
+ public private(set) var phase: Phase = .idle
+ public private(set) var summary = OutboxSummary()
+ public private(set) var sessionEnded = false
+ public init(outbox: Outbox, transport: @escaping @MainActor () -> NativeTransport?, connectivity: NativeConnectivity,
+ maxBatchBytes: Int? = nil, backoff: Backoff = Backoff(), send: @escaping Send) { self.outbox = outbox }
+ public func start() {}
+ public func stop() {}
+ public func kick() {}
+ public func retryNow() {}
+ public func sessionRestored() {}
+ @discardableResult public func drain() async -> Outcome { .idle }
+ public func status(lane: String? = nil, localFailure: SyncFailure? = nil,
+ count: (OutboxSummary) -> Int = { $0.pendingLanes }) -> SyncStatus { .saved }
+}
diff --git a/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings
new file mode 100644
index 0000000..c91be22
--- /dev/null
+++ b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings
@@ -0,0 +1 @@
+"stub" = "stub";
diff --git a/Sources/RastrilloNative/BlobStore.swift b/Sources/RastrilloNative/BlobStore.swift
new file mode 100644
index 0000000..04edf31
--- /dev/null
+++ b/Sources/RastrilloNative/BlobStore.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+/// Why a store operation failed. The three cases are kept apart because each
+/// one asks for a different reaction, and folding them together is how a
+/// locked phone ends up deleting the changes it was keeping.
+public enum BlobStoreError: Error, Sendable, Equatable {
+ /// The device is locked, file protection refused the read, or the Keychain
+ /// would not answer. Retry later (on `.active` or unlock). Never read this
+ /// as "empty", and never delete anything because of it.
+ case unavailable
+ /// The account's key is gone (`errSecItemNotFound`) while its directory
+ /// still held sealed files. The store has already removed that directory,
+ /// because nothing in it can ever be read again; the caller reports the
+ /// loss once and carries on with an empty store.
+ case keyLost
+ /// One file failed its authentication tag. The store has removed that
+ /// file, and only that file; the path is given so it can be reported once.
+ case corrupt(String)
+}
+
+/// Durable bytes by relative path, for one account. `NativeSealedStore` is the
+/// real one; tests use `MemoryBlobStore` and `FaultingBlobStore`.
+///
+/// Paths are relative, `/`-separated, and never renamed once written: a
+/// sealed file is bound to its path.
+public protocol BlobStore: Sendable {
+ /// Atomic: after a crash the path holds either the old bytes or the new
+ /// ones. Durable on return means it survives a process kill, not a power
+ /// loss.
+ func write(_ data: Data, to path: String) throws
+ /// nil when there is no such file.
+ func read(_ path: String) throws -> Data?
+ /// The names (not paths) of the files directly in `directory`, sorted. An
+ /// absent directory lists as empty.
+ func list(_ directory: String) throws -> [String]
+ /// Removes a file or a directory. Removing what is not there succeeds.
+ func remove(_ path: String) throws
+}
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift
new file mode 100644
index 0000000..5c5afb4
--- /dev/null
+++ b/Sources/RastrilloNative/Outbox.swift
@@ -0,0 +1,119 @@
+import Foundation
+
+/// One queued change, durable on disk until the server has it.
+public struct OutboxEntry: Codable, Sendable, Identifiable, Equatable {
+ /// Stable for the entry's life; apps may send it as an idempotency key.
+ public let id: UUID
+ /// Per account and monotonic, recovered as max + 1 at load. It orders a lane.
+ public let seq: UInt64
+ /// The FIFO scope, such as "doc:<key>". It never changes.
+ public let lane: String
+ /// App-defined.
+ public let kind: String
+ /// Opaque app bytes.
+ public let payload: Data
+ public let createdAt: Date
+ /// Failed sends so far. Counted in memory; written with the next rewrite.
+ public var attempts: Int
+ /// nil means sendable.
+ public var hold: Hold?
+
+ public init(id: UUID, seq: UInt64, lane: String, kind: String, payload: Data, createdAt: Date, attempts: Int = 0, hold: Hold? = nil) {
+ self.id = id; self.seq = seq; self.lane = lane; self.kind = kind; self.payload = payload
+ self.createdAt = createdAt; self.attempts = attempts; self.hold = hold
+ }
+}
+
+/// Why an entry stopped: the person has to decide what happens to it.
+public struct Hold: Codable, Sendable, Equatable {
+ public enum Reason: Codable, Sendable, Equatable {
+ case conflict(Data)
+ case rejected(String)
+ case accessLost
+ case tooLarge
+ }
+ public let reason: Reason
+ /// Every v1 hold stops its lane. A hold that lets later entries pass
+ /// arrives with Sheets, whose writes to different cells do not depend on
+ /// each other; Docs' updates do, so it never needs one.
+ public var blocksLane: Bool { true }
+ public init(_ reason: Reason) { self.reason = reason }
+}
+
+/// Per-lane counts, for status and for the app's own caps.
+public struct OutboxSummary: Sendable, Equatable {
+ public struct Lane: Sendable, Equatable {
+ /// Entries not held, including any in flight.
+ public var pending = 0
+ public var held = 0
+ public var inFlight = 0
+ /// Payload bytes of every entry in the lane, held or not.
+ public var bytes = 0
+ public var firstHold: Hold.Reason?
+ public init() {}
+ }
+ public var lanes: [String: Lane] = [:]
+ /// The store could not be read. Counts are the last ones known, which is
+ /// never the same thing as "nothing pending".
+ public var unavailable = false
+ /// Unsent entries were lost to a lost key or a corrupt file. Report it
+ /// once, then call `Outbox.acknowledgeLoss()`.
+ public var lostUnsent = false
+
+ public init() {}
+
+ public var pending: Int { lanes.values.reduce(0) { $0 + $1.pending } }
+ public var held: Int { lanes.values.reduce(0) { $0 + $1.held } }
+ public var inFlight: Int { lanes.values.reduce(0) { $0 + $1.inFlight } }
+ /// Lanes with anything in them: the default "N" of "Offline · N pending".
+ public var pendingLanes: Int { lanes.values.filter { $0.pending + $0.held > 0 }.count }
+ public var isEmpty: Bool { lanes.values.allSatisfy { $0.pending + $0.held == 0 } }
+
+ /// The same summary, narrowed to one lane.
+ public func only(_ lane: String) -> OutboxSummary {
+ var copy = self
+ copy.lanes = lanes[lane].map { [lane: $0] } ?? [:]
+ return copy
+ }
+}
+
+public enum OutboxError: Error, Sendable, Equatable {
+ /// `replace` was asked to merge an entry that is being sent. The batch's
+ /// `ack` would otherwise delete the merged file, and with it content whose
+ /// own files the merge had already removed.
+ case inFlight
+ /// `replace` needs a run of entries that are next to each other in one lane.
+ case notContiguous
+ /// `replace` never merges a held entry: it is waiting for a person.
+ case held
+ case unknownEntry(UUID)
+ case duplicateEntry(UUID)
+}
+
+/// STUB for the red run: every behaviour missing.
+public actor Outbox {
+ public nonisolated let account: String
+ public static let directory = "outbox"
+ public init(store: BlobStore, account: String) { self.account = account }
+ public func load() throws {}
+ @discardableResult
+ public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry {
+ OutboxEntry(id: id, seq: 1, lane: lane, kind: kind, payload: payload, createdAt: Date())
+ }
+ public func entries(lane: String) -> [OutboxEntry] { [] }
+ public func sendable(lane: String, maxBytes: Int?) -> [OutboxEntry] { [] }
+ public func beginSend(lane: String, maxBytes: Int?) throws -> [OutboxEntry] { [] }
+ public func endSend(_ ids: [UUID], failed: Bool = true) {}
+ @discardableResult
+ public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry {
+ OutboxEntry(id: UUID(), seq: 0, lane: "", kind: "", payload: payload, createdAt: Date())
+ }
+ public func ack(_ ids: [UUID]) throws {}
+ public func hold(_ ids: [UUID], _ hold: Hold) throws {}
+ public func release(_ id: UUID) throws {}
+ public func discard(lane: String) throws {}
+ public func purge() throws {}
+ public func acknowledgeLoss() {}
+ public var summary: OutboxSummary { OutboxSummary() }
+ public var changes: AsyncStream<OutboxSummary> { AsyncStream { $0.finish() } }
+}
diff --git a/Sources/RastrilloNative/SyncStatus.swift b/Sources/RastrilloNative/SyncStatus.swift
new file mode 100644
index 0000000..5c20188
--- /dev/null
+++ b/Sources/RastrilloNative/SyncStatus.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+/// A failure the person has to see and act on.
+public enum SyncFailure: Sendable, Equatable {
+ /// An entry is held; the app owns the wording and the action.
+ case held(Hold.Reason)
+ /// Enqueueing failed (disk full, or the store is unreadable): input pauses.
+ case cannotSaveOnDevice
+ /// The app's per-lane byte ceiling was reached: input stops, and says why.
+ case tooManyUnsent
+}
+
+/// What the save status says, computed from outbox state and never from HTTP
+/// activity. The cases are in priority order: the first that applies wins.
+///
+/// "Offline" with nothing pending is `.saved`: status stays silent and the
+/// connectivity chrome says offline, so "Offline · 0 pending" never appears.
+public enum SyncStatus: Sendable, Equatable {
+ /// 1. A held entry, or a local failure. Sticky until resolved.
+ case failed(pending: Int, reason: SyncFailure)
+ /// 2. The session ended with changes still to send. Sticky until sign-in.
+ case needsSignIn(pending: Int)
+ /// 3. Nothing to send and nothing in flight. Silent.
+ case saved
+ /// 4. A send is in flight (or about to start).
+ case saving
+ /// 5. Changes are waiting for a connection.
+ case offline(pending: Int)
+ /// 6. Backing off after a transient failure.
+ case retrying(pending: Int)
+
+ public enum SaveState: String, Sendable { case saved, saving, retrying, failed }
+ public var saveState: SaveState { .saved }
+ public var isSticky: Bool { false }
+ public var isAnnounced: Bool { false }
+ public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool,
+ localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { self = .saved }
+}
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
new file mode 100644
index 0000000..14365fc
--- /dev/null
+++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift
@@ -0,0 +1,44 @@
+import CryptoKit
+import Foundation
+import RastrilloApple
+import RastrilloNative
+import Security
+
+/// STUB for the red run.
+public final class MemoryBlobStore: BlobStore, @unchecked Sendable {
+ public init(_ files: [String: Data] = [:]) {}
+ public var contents: [String: Data] { [:] }
+ public func write(_ data: Data, to path: String) throws {}
+ public func read(_ path: String) throws -> Data? { nil }
+ public func list(_ directory: String) throws -> [String] { [] }
+ public func remove(_ path: String) throws {}
+}
+public struct SimulatedCrash: Error, Sendable, Equatable {
+ public let operation: Int
+ public init(operation: Int) { self.operation = operation }
+}
+public final class FaultingBlobStore: BlobStore, @unchecked Sendable {
+ public enum Operation: Sendable, Equatable { case write(String), read(String), list(String), remove(String) }
+ private let base: MemoryBlobStore
+ public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base }
+ public func crash(after operations: Int) {}
+ public var unavailable: Bool { get { false } set {} }
+ public var hasCrashed: Bool { false }
+ public var operationCount: Int { 0 }
+ public var operations: [Operation] { [] }
+ public var committed: MemoryBlobStore { MemoryBlobStore() }
+ public func write(_ data: Data, to path: String) throws { try base.write(data, to: path) }
+ public func read(_ path: String) throws -> Data? { try base.read(path) }
+ public func list(_ directory: String) throws -> [String] { try base.list(directory) }
+ public func remove(_ path: String) throws { try base.remove(path) }
+}
+public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable {
+ public init(key: SymmetricKey? = nil) {}
+ public func failCopies(with status: OSStatus?) {}
+ public func failAdds(with status: OSStatus?) {}
+ public var storedKey: SymmetricKey? { nil }
+ public var log: [String] { [] }
+ public func copyKey() -> (OSStatus, SymmetricKey?) { (errSecItemNotFound, nil) }
+ public func addKey(_ new: SymmetricKey) -> OSStatus { errSecSuccess }
+ public func deleteKey() -> OSStatus { errSecSuccess }
+}
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift
new file mode 100644
index 0000000..a7baacb
--- /dev/null
+++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift
@@ -0,0 +1,272 @@
+import Foundation
+import RastrilloNative
+import RastrilloTestSupport
+import XCTest
+@testable import RastrilloApple
+
+@MainActor private func settle(until condition: () async -> Bool, seconds: Double = 3) async {
+ let deadline = Date().addingTimeInterval(seconds)
+ while !(await condition()) && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) }
+}
+
+/// A send that POSTs each entry's payload, in order, as one request per batch.
+@MainActor private func poster(_ calls: @escaping @MainActor (String, [OutboxEntry]) -> Void = { _, _ in }) -> OutboxDrainer.Send {
+ { lane, entries, transport in
+ calls(lane, entries)
+ let body = entries.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: ",")
+ let reply = try await transport.responseDetails("/\(lane)", method: "POST", body: Data(body.utf8))
+ try reply.requireSuccess()
+ return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) })
+ }
+}
+
+@MainActor final class OutboxDrainerTests: XCTestCase {
+ private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, ScriptedPathSource) {
+ let source = ScriptedPathSource(status)
+ let connectivity = NativeConnectivity(source: source)
+ await settle { connectivity.path?.status == status }
+ return (connectivity, source)
+ }
+
+ private func outbox(_ lanes: [(String, String)]) async throws -> Outbox {
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ for (lane, text) in lanes { try await outbox.enqueue(lane: lane, kind: "u", payload: Data(text.utf8)) }
+ return outbox
+ }
+
+ private func bodies(_ transport: ScriptedTransport) -> [String] {
+ transport.committed.map { String(decoding: $0.body ?? Data(), as: UTF8.self) }
+ }
+
+ func testDrainsEachLaneInOrderAndAcknowledges() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
+ let outbox = try await outbox([("doc:a", "1"), ("doc:b", "x"), ("doc:a", "2")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome = await drainer.drain()
+ XCTAssertEqual(outcome, .idle)
+ XCTAssertEqual(transport.committed.map(\.path), ["/doc:a", "/doc:b"])
+ XCTAssertEqual(bodies(transport), ["1,2", "x"], "One batch per lane, FIFO")
+ let summary = await outbox.summary
+ XCTAssertTrue(summary.isEmpty)
+ XCTAssertEqual(drainer.status(), .saved)
+ }
+
+ func testBatchesAreCutByBytes() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
+ let outbox = try await outbox([("doc:a", "aaaa"), ("doc:a", "bbbb"), ("doc:a", "cccc")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, maxBatchBytes: 8, send: poster())
+ await drainer.drain()
+ XCTAssertEqual(bodies(transport), ["aaaa,bbbb", "cccc"])
+ }
+
+ func testAPathDownMakesNoAttemptAndWaits() async throws {
+ let (connectivity, _) = await connectivity(.unsatisfied)
+ let transport = ScriptedTransport([])
+ let outbox = try await outbox([("doc:a", "1")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome = await drainer.drain()
+ XCTAssertEqual(outcome, .waitingForNetwork)
+ XCTAssertEqual(transport.calls.count, 0, "An offline phone makes no attempts")
+ XCTAssertEqual(drainer.status(), .offline(pending: 1))
+ }
+
+ func testOfflineMidSendWithThePathDownWaitsForTheNetwork() async throws {
+ let (connectivity, source) = await connectivity()
+ let outbox = try await outbox([("doc:a", "1")])
+ let transport = ScriptedTransport(responder: { _ in
+ source.send(.unsatisfied)
+ return .offline(.notConnectedToInternet)
+ })
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ await settle { connectivity.isPathSatisfied }
+ let first = await drainer.drain()
+ await settle { !connectivity.isPathSatisfied }
+ // Whether the path update landed before or after the classification,
+ // the next drain must not try again while the path is down.
+ let second = await drainer.drain()
+ XCTAssertEqual(second, .waitingForNetwork, "first was \(first)")
+ XCTAssertEqual(transport.calls.count, 1)
+ }
+
+ /// M2: a lost response or timeout while the path stays up backs off and
+ /// retries the same batch; it does not wait for a path change.
+ func testALostResponseOnAWorkingPathBacksOffAndResendsTheSameBatch() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.commitThenLoseResponse(), .respond(status: 204)])
+ let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let first = await drainer.drain()
+ guard case .backingOff(let delay) = first else { return XCTFail("Expected a backoff, got \(first)") }
+ XCTAssertGreaterThan(delay, 0)
+ XCTAssertEqual(drainer.phase, .backingOff)
+ XCTAssertEqual(drainer.status(), .retrying(pending: 1))
+ let kept = await outbox.entries(lane: "doc:a")
+ XCTAssertEqual(kept.count, 2, "Nothing is acknowledged that the drainer did not see acknowledged")
+ let outcome1 = await drainer.drain()
+ XCTAssertEqual(outcome1, .idle)
+ XCTAssertEqual(bodies(transport), ["1,2", "1,2"], "The same batch, sent again")
+ }
+
+ func testAnEndedSessionStopsEveryLaneAndHoldsNothing() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.respond(status: 401), .respond(status: 204), .respond(status: 204)])
+ let outbox = try await outbox([("doc:a", "1"), ("doc:b", "2")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome2 = await drainer.drain()
+ XCTAssertEqual(outcome2, .sessionEnded)
+ XCTAssertEqual(transport.calls.count, 1, "Every lane stops")
+ let summary = await outbox.summary
+ XCTAssertEqual(summary.held, 0, "Never hold or drop on 401")
+ XCTAssertEqual(summary.pending, 2)
+ XCTAssertEqual(drainer.status(), .needsSignIn(pending: 2))
+ let outcome3 = await drainer.drain()
+ XCTAssertEqual(outcome3, .sessionEnded, "It stays stopped until signed in again")
+ drainer.sessionRestored()
+ let outcome4 = await drainer.drain()
+ XCTAssertEqual(outcome4, .idle)
+ XCTAssertEqual(transport.calls.count, 3)
+ }
+
+ func testNoSessionIsAnEndedSession() async throws {
+ let (connectivity, _) = await connectivity()
+ let outbox = try await outbox([("doc:a", "1")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { nil }, connectivity: connectivity, send: poster())
+ let outcome5 = await drainer.drain()
+ XCTAssertEqual(outcome5, .sessionEnded)
+ }
+
+ func testRefusalsHoldTheirLaneAndOtherLanesKeepDraining() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport { call in
+ switch call.path {
+ case "/doc:gone": return .respond(status: 403)
+ case "/doc:big": return .respond(status: 413)
+ case "/doc:bad": return .respond(status: 400)
+ default: return .respond(status: 204)
+ }
+ }
+ let outbox = try await outbox([("doc:gone", "1"), ("doc:big", "2"), ("doc:bad", "3"), ("doc:ok", "4")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome6 = await drainer.drain()
+ XCTAssertEqual(outcome6, .idle)
+ let summary = await outbox.summary
+ XCTAssertEqual(summary.lanes["doc:gone"]?.firstHold, .accessLost)
+ XCTAssertEqual(summary.lanes["doc:big"]?.firstHold, .tooLarge)
+ XCTAssertEqual(summary.lanes["doc:bad"]?.firstHold, .rejected("400"))
+ XCTAssertNil(summary.lanes["doc:ok"], "Other lanes are not stopped by a hold")
+ XCTAssertEqual(transport.calls.count, 4, "A held lane is not retried")
+ XCTAssertEqual(drainer.status(lane: "doc:gone"), .failed(pending: 1, reason: .held(.accessLost)))
+ XCTAssertEqual(drainer.status(lane: "doc:ok"), .saved)
+ }
+
+ func testRateLimitingIsAFloorOnTheDelay() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.respond(status: 429, headers: ["retry-after": "30"])])
+ let outbox = try await outbox([("doc:a", "1")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome = await drainer.drain()
+ guard case .rateLimited(let delay) = outcome else { return XCTFail("Expected rate limiting, got \(outcome)") }
+ XCTAssertGreaterThanOrEqual(delay, 30)
+ }
+
+ func testServerErrorsBackOff() async throws {
+ let (connectivity, _) = await connectivity()
+ let transport = ScriptedTransport([.respond(status: 502)])
+ let outbox = try await outbox([("doc:a", "1")])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let backedOff = await drainer.drain()
+ guard case .backingOff = backedOff else { return XCTFail("Expected a backoff") }
+ let summary = await outbox.summary
+ XCTAssertEqual(summary.pending, 1)
+ XCTAssertEqual(summary.held, 0)
+ }
+
+ /// H4: the send went through but the store locked before the ack. The
+ /// batch stays queued and goes again; it is never lost.
+ func testALockedStoreDuringTheAckIsARetry() async throws {
+ let (connectivity, _) = await connectivity()
+ let disk = FaultingBlobStore()
+ let outbox = Outbox(store: disk, account: "a")
+ try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
+ let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity,
+ send: poster { _, _ in disk.unavailable = true })
+ let backedOff = await drainer.drain()
+ guard case .backingOff = backedOff else { return XCTFail("Expected a retry") }
+ disk.unavailable = false
+ let kept = await outbox.entries(lane: "doc:a")
+ XCTAssertEqual(kept.count, 1)
+ let again = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome7 = await again.drain()
+ XCTAssertEqual(outcome7, .idle)
+ XCTAssertEqual(transport.committed.count, 2)
+ }
+
+ func testAnUnavailableStoreWaitsAndDeletesNothing() async throws {
+ let (connectivity, _) = await connectivity()
+ let disk = FaultingBlobStore()
+ let setup = Outbox(store: disk, account: "a")
+ try await setup.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
+ disk.unavailable = true
+ let outbox = Outbox(store: disk, account: "a")
+ let transport = ScriptedTransport([])
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ let outcome = await drainer.drain()
+ XCTAssertTrue(outcome == .storeUnavailable || outcome == .idle, "\(outcome)")
+ XCTAssertEqual(transport.calls.count, 0)
+ disk.unavailable = false
+ XCTAssertEqual(disk.committed.contents.count, 1)
+ }
+
+ func testAppDispositionsRetryHoldAndSkip() async throws {
+ let (connectivity, _) = await connectivity()
+ let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2"), ("doc:a", "3")])
+ let transport = ScriptedTransport([])
+ var round = 0
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in
+ round += 1
+ // Round 1: only the first could be sent (a merge fallback); the rest
+ // are left out and go back without penalty.
+ if round == 1 { return [entries[0].id: .sent] }
+ return [entries[0].id: .hold(Hold(.rejected("no")))]
+ }
+ let outcome8 = await drainer.drain()
+ XCTAssertEqual(outcome8, .idle)
+ XCTAssertEqual(round, 2)
+ let lane = await outbox.entries(lane: "doc:a")
+ XCTAssertEqual(lane.map { String(decoding: $0.payload, as: UTF8.self) }, ["2", "3"])
+ XCTAssertEqual(lane.first?.hold, Hold(.rejected("no")))
+ XCTAssertEqual(lane.last?.attempts, 0, "A left-out entry is not a failed attempt")
+ }
+
+ /// The background loop: an enqueue kicks it, and a restored path wakes it
+ /// with no timer running in between.
+ func testTheLoopSendsOnKickAndOnReconnect() async throws {
+ let (connectivity, source) = await connectivity()
+ let transport = ScriptedTransport { _ in .respond(status: 204) }
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
+ drainer.start()
+ defer { drainer.stop() }
+ try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
+ drainer.kick()
+ await settle { transport.committed.count == 1 }
+ XCTAssertEqual(transport.committed.count, 1)
+
+ source.send(.unsatisfied)
+ await settle { !connectivity.isPathSatisfied }
+ try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("2".utf8))
+ drainer.kick()
+ try await Task.sleep(nanoseconds: 100_000_000)
+ XCTAssertEqual(transport.calls.count, 1, "Nothing is attempted while the path is down")
+ await settle { drainer.status() == .offline(pending: 1) }
+ XCTAssertEqual(drainer.status(), .offline(pending: 1))
+ source.send(.satisfied)
+ await settle { transport.committed.count == 2 }
+ XCTAssertEqual(bodies(transport), ["1", "2"])
+ await settle { drainer.summary.isEmpty }
+ XCTAssertEqual(drainer.status(), .saved)
+ }
+}
diff --git a/Tests/RastrilloAppleTests/SealedStoreTests.swift b/Tests/RastrilloAppleTests/SealedStoreTests.swift
new file mode 100644
index 0000000..d4be5cd
--- /dev/null
+++ b/Tests/RastrilloAppleTests/SealedStoreTests.swift
@@ -0,0 +1,175 @@
+import CryptoKit
+import Foundation
+import RastrilloNative
+import RastrilloTestSupport
+import Security
+import XCTest
+@testable import RastrilloApple
+
+final class SealedStoreTests: XCTestCase {
+ var directory: URL!
+
+ override func setUpWithError() throws {
+ directory = FileManager.default.temporaryDirectory.appendingPathComponent("sealed-\(UUID().uuidString)", isDirectory: true)
+ }
+
+ override func tearDownWithError() throws {
+ try? FileManager.default.removeItem(at: directory)
+ }
+
+ private func store(_ account: String = "account-a", keys: KeyProvider) -> NativeSealedStore {
+ NativeSealedStore(root: directory.appendingPathComponent(account, isDirectory: true), accountID: account, keys: keys)
+ }
+
+ private func file(_ account: String, _ path: String) -> URL {
+ directory.appendingPathComponent(account).appendingPathComponent(path)
+ }
+
+ func testRoundTripIsSealedWithAFreshNonceAndBoundToItsPath() throws {
+ let keys = ScriptedKeyProvider()
+ let sealed = store(keys: keys)
+ let secret = Data("the quarterly numbers".utf8)
+ try sealed.write(secret, to: "outbox/0000000000000001.entry")
+ XCTAssertEqual(try sealed.read("outbox/0000000000000001.entry"), secret)
+ XCTAssertNotNil(keys.storedKey, "The key is created on first write")
+
+ let onDisk = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry"))
+ XCTAssertNil(onDisk.range(of: secret), "Plaintext must never reach the disk")
+ XCTAssertEqual(onDisk.count, 12 + secret.count + 16, ".combined is nonce, ciphertext, tag")
+
+ try sealed.write(secret, to: "outbox/0000000000000001.entry")
+ let again = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry"))
+ XCTAssertNotEqual(onDisk.prefix(12), again.prefix(12), "Every seal draws a fresh nonce")
+
+ // The associated data is exactly accountID | format version | path.
+ let box = try AES.GCM.SealedBox(combined: again)
+ XCTAssertEqual(try AES.GCM.open(box, using: keys.storedKey!, authenticating: Data("account-a|1|outbox/0000000000000001.entry".utf8)), secret)
+ XCTAssertEqual(try sealed.list("outbox"), ["0000000000000001.entry"])
+ XCTAssertNil(try sealed.read("outbox/missing.entry"))
+ }
+
+ func testATamperedFileFailsAndIsRemovedAlone() throws {
+ let sealed = store(keys: ScriptedKeyProvider())
+ try sealed.write(Data("one".utf8), to: "cache/docs/a/replica")
+ try sealed.write(Data("two".utf8), to: "cache/docs/b/replica")
+ var bytes = try Data(contentsOf: file("account-a", "cache/docs/a/replica"))
+ bytes[bytes.count - 1] ^= 0x01
+ try bytes.write(to: file("account-a", "cache/docs/a/replica"))
+
+ XCTAssertThrowsError(try sealed.read("cache/docs/a/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/a/replica")) }
+ XCTAssertFalse(FileManager.default.fileExists(atPath: file("account-a", "cache/docs/a/replica").path))
+ XCTAssertEqual(try sealed.read("cache/docs/b/replica"), Data("two".utf8), "The directory is never removed for one file")
+ }
+
+ func testARenamedFileFailsToOpen() throws {
+ let sealed = store(keys: ScriptedKeyProvider())
+ try sealed.write(Data("document A".utf8), to: "cache/docs/a/replica")
+ try sealed.write(Data("document B".utf8), to: "cache/docs/b/replica")
+ // Swap A's replica in as B's.
+ try FileManager.default.removeItem(at: file("account-a", "cache/docs/b/replica"))
+ try FileManager.default.copyItem(at: file("account-a", "cache/docs/a/replica"), to: file("account-a", "cache/docs/b/replica"))
+ XCTAssertThrowsError(try sealed.read("cache/docs/b/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/b/replica")) }
+ XCTAssertEqual(try sealed.read("cache/docs/a/replica"), Data("document A".utf8))
+ }
+
+ func testAFileFromAnotherAccountFailsToOpenEvenUnderTheSameKey() throws {
+ let key = SymmetricKey(size: .bits256)
+ let a = store("account-a", keys: ScriptedKeyProvider(key: key))
+ let b = store("account-b", keys: ScriptedKeyProvider(key: key))
+ try a.write(Data("A's entry".utf8), to: "outbox/0000000000000001.entry")
+ try b.write(Data("B's entry".utf8), to: "outbox/0000000000000001.entry")
+ try FileManager.default.removeItem(at: file("account-b", "outbox/0000000000000001.entry"))
+ try FileManager.default.copyItem(at: file("account-a", "outbox/0000000000000001.entry"), to: file("account-b", "outbox/0000000000000001.entry"))
+ XCTAssertThrowsError(try b.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("outbox/0000000000000001.entry")) }
+ }
+
+ func testALockedKeychainIsUnavailableAndDeletesNothing() throws {
+ let keys = ScriptedKeyProvider()
+ try store(keys: keys).write(Data("kept".utf8), to: "outbox/0000000000000001.entry")
+ for status in [errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable, OSStatus(-1)] {
+ keys.failCopies(with: status)
+ let relaunched = store(keys: keys) // no cached key: it must ask the Keychain
+ XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry"), "status \(status)") {
+ XCTAssertEqual($0 as? BlobStoreError, .unavailable)
+ }
+ XCTAssertThrowsError(try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")) {
+ XCTAssertEqual($0 as? BlobStoreError, .unavailable)
+ }
+ XCTAssertTrue(FileManager.default.fileExists(atPath: file("account-a", "outbox/0000000000000001.entry").path), "status \(status) deleted a file")
+ XCTAssertFalse(keys.log.contains("delete"))
+ }
+ keys.failCopies(with: nil)
+ XCTAssertEqual(try store(keys: keys).read("outbox/0000000000000001.entry"), Data("kept".utf8), "Unlocking reads everything again")
+ }
+
+ func testALostKeyWithFilesPresentRemovesTheDirectoryOnce() throws {
+ let old = store(keys: ScriptedKeyProvider())
+ try old.write(Data("sealed under a key that is gone".utf8), to: "outbox/0000000000000001.entry")
+ try old.write(Data("cache".utf8), to: "cache/docs/a/replica")
+ let fresh = ScriptedKeyProvider() // errSecItemNotFound
+ let relaunched = store(keys: fresh)
+ XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .keyLost) }
+ XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path))
+ // After the loss, the store starts again with a new key.
+ try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")
+ XCTAssertEqual(try relaunched.read("outbox/0000000000000002.entry"), Data("new".utf8))
+ XCTAssertNotNil(fresh.storedKey)
+ }
+
+ func testNoKeyAndNoFilesIsAFirstWriteNotALoss() throws {
+ let keys = ScriptedKeyProvider()
+ let sealed = store(keys: keys)
+ XCTAssertNil(try sealed.read("outbox/0000000000000001.entry"))
+ XCTAssertEqual(try sealed.list("outbox"), [])
+ XCTAssertEqual(keys.log, [], "A missing file needs no key")
+ try sealed.write(Data("first".utf8), to: "outbox/0000000000000001.entry")
+ XCTAssertEqual(keys.log, ["copy", "add"])
+ }
+
+ func testAFailedKeyCreationIsUnavailable() throws {
+ let keys = ScriptedKeyProvider()
+ keys.failAdds(with: errSecInteractionNotAllowed)
+ XCTAssertThrowsError(try store(keys: keys).write(Data("x".utf8), to: "outbox/0000000000000001.entry")) {
+ XCTAssertEqual($0 as? BlobStoreError, .unavailable)
+ }
+ }
+
+ func testTheAccountDirectoryIsExcludedFromBackup() throws {
+ let sealed = store(keys: ScriptedKeyProvider())
+ try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry")
+ let values = try directory.appendingPathComponent("account-a").resourceValues(forKeys: [.isExcludedFromBackupKey])
+ XCTAssertEqual(values.isExcludedFromBackup, true)
+ #if os(iOS)
+ XCTAssertTrue(NativeSealedStore.writeOptions.contains(.completeFileProtection))
+ #endif
+ XCTAssertTrue(NativeSealedStore.writeOptions.contains(.atomic))
+ }
+
+ func testWipeDeletesTheKeyFirstThenTheDirectory() throws {
+ let keys = ScriptedKeyProvider()
+ let sealed = store(keys: keys)
+ try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry")
+ try sealed.wipe()
+ XCTAssertNil(keys.storedKey)
+ XCTAssertEqual(keys.log.last, "delete")
+ XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path))
+ }
+
+ func testPathsCannotEscapeTheAccountDirectory() {
+ let sealed = store(keys: ScriptedKeyProvider())
+ for path in ["../other/outbox/1.entry", "/etc/passwd", "outbox/../../x", ""] {
+ XCTAssertThrowsError(try sealed.write(Data(), to: path), path)
+ }
+ }
+
+ /// The outbox over the real sealed store survives a relaunch, and an
+ /// entry copied from another account cannot be smuggled into this one.
+ func testAnOutboxRelaunchesFromSealedFiles() async throws {
+ let keys = ScriptedKeyProvider()
+ let outbox = Outbox(store: store(keys: keys), account: "account-a")
+ let entry = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: Data("typed".utf8))
+ let relaunched = Outbox(store: store(keys: keys), account: "account-a")
+ let lane = await relaunched.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.id), [entry.id])
+ }
+}
diff --git a/Tests/RastrilloAppleTests/SyncStringsTests.swift b/Tests/RastrilloAppleTests/SyncStringsTests.swift
new file mode 100644
index 0000000..f784e59
--- /dev/null
+++ b/Tests/RastrilloAppleTests/SyncStringsTests.swift
@@ -0,0 +1,63 @@
+import Foundation
+import RastrilloNative
+import XCTest
+@testable import RastrilloApple
+
+final class SyncStringsTests: XCTestCase {
+ static let languages = ["ar", "bn", "en", "es", "ga", "hi", "ja", "pt", "ru", "vi", "yue", "zh-Hans"]
+ static let keys = ["sync.saving", "sync.retrying", "sync.offline", "sync.needsSignIn", "sync.signIn",
+ "sync.failed.cannotSave", "sync.failed.tooManyUnsent", "sync.failed.held",
+ "offline.waiting", "offline.durable", "sync.unsentLost"]
+
+ /// The catalog ships in the same twelve languages as the apps, and no
+ /// language is missing a string: a missing one would show its key.
+ func testEveryLanguageHasEveryString() throws {
+ for language in Self.languages {
+ let strings = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "strings", inDirectory: nil, forLocalization: language), language)
+ let table = try XCTUnwrap(NSDictionary(contentsOfFile: strings) as? [String: String], language)
+ XCTAssertEqual(Set(table.keys), Set(Self.keys), language)
+ for (key, value) in table { XCTAssertFalse(value.isEmpty, "\(language) \(key)") }
+ let plurals = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "stringsdict", inDirectory: nil, forLocalization: language), language)
+ let rules = try XCTUnwrap(NSDictionary(contentsOfFile: plurals) as? [String: Any], language)
+ XCTAssertEqual(Set(rules.keys), ["sync.offline", "sync.needsSignIn"], language)
+ for key in rules.keys {
+ let rule = try XCTUnwrap((rules[key] as? [String: Any])?["n"] as? [String: String], "\(language) \(key)")
+ XCTAssertNotNil(rule["other"], "\(language) \(key) needs an other form")
+ for (category, text) in rule where !category.hasPrefix("NSString") {
+ XCTAssertTrue(text.contains("%d"), "\(language) \(key) \(category) drops the count")
+ }
+ }
+ }
+ }
+
+ func testEnglishWording() {
+ let strings = NativeSyncStrings.kit(localization: "en")
+ XCTAssertNil(strings.text(for: .saved), "Saved is silent")
+ XCTAssertEqual(strings.text(for: .saving), "Saving…")
+ XCTAssertEqual(strings.text(for: .offline(pending: 1)), "Offline · 1 pending")
+ XCTAssertEqual(strings.text(for: .offline(pending: 3)), "Offline · 3 pending")
+ XCTAssertEqual(strings.text(for: .retrying(pending: 2)), "Trouble saving · Retrying…")
+ XCTAssertEqual(strings.text(for: .needsSignIn(pending: 2)), "Sign in to send 2 pending")
+ XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .tooManyUnsent)),
+ "Too many unsent changes to keep on this device. Connect to save.")
+ XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .cannotSaveOnDevice)), "Can’t save on this device.")
+ XCTAssertEqual(strings.offlineDurable,
+ "You’re offline. Changes are saved on this device and will be sent when you reconnect.")
+ XCTAssertEqual(strings.offlineWaiting, "You’re offline. Waiting for a connection.")
+ }
+
+ func testPluralRulesAreApplied() {
+ let russian = NativeSyncStrings.kit(localization: "ru")
+ XCTAssertEqual(russian.text(for: .offline(pending: 1)), "Не в сети · 1 ожидает отправки")
+ XCTAssertEqual(russian.text(for: .offline(pending: 3)), "Не в сети · 3 ожидают отправки")
+ let spanish = NativeSyncStrings.kit(localization: "es")
+ XCTAssertEqual(spanish.text(for: .offline(pending: 1)), "Sin conexión · 1 pendiente")
+ XCTAssertEqual(spanish.text(for: .offline(pending: 2)), "Sin conexión · 2 pendientes")
+ }
+
+ func testAnAppOverridesWithACopy() {
+ var strings = NativeSyncStrings.kit(localization: "en")
+ strings.offline = { "Offline · \($0) cells pending" }
+ XCTAssertEqual(strings.text(for: .offline(pending: 4)), "Offline · 4 cells pending")
+ }
+}
diff --git a/Tests/RastrilloNativeTests/OutboxTests.swift b/Tests/RastrilloNativeTests/OutboxTests.swift
new file mode 100644
index 0000000..ff23d4c
--- /dev/null
+++ b/Tests/RastrilloNativeTests/OutboxTests.swift
@@ -0,0 +1,309 @@
+import Foundation
+import RastrilloNative
+import RastrilloTestSupport
+import XCTest
+
+private func bytes(_ text: String) -> Data { Data(text.utf8) }
+
+/// Answers `.corrupt` for chosen paths, removing them, as the sealed store does.
+private final class CorruptingStore: BlobStore, @unchecked Sendable {
+ let base: MemoryBlobStore
+ let corrupt: Set<String>
+ init(_ base: MemoryBlobStore, corrupt: Set<String>) { self.base = base; self.corrupt = corrupt }
+ func write(_ data: Data, to path: String) throws { try base.write(data, to: path) }
+ func read(_ path: String) throws -> Data? {
+ if corrupt.contains(path) { try base.remove(path); throw BlobStoreError.corrupt(path) }
+ return try base.read(path)
+ }
+ func list(_ directory: String) throws -> [String] { try base.list(directory) }
+ func remove(_ path: String) throws { try base.remove(path) }
+}
+
+/// Answers `.keyLost` on the first read and removes everything, as the
+/// sealed store does when the Keychain has lost the key.
+private final class KeyLosingStore: BlobStore, @unchecked Sendable {
+ let base: MemoryBlobStore
+ init(_ base: MemoryBlobStore) { self.base = base }
+ func write(_ data: Data, to path: String) throws { try base.write(data, to: path) }
+ func read(_ path: String) throws -> Data? {
+ guard try base.read(path) != nil else { return nil }
+ try base.remove("")
+ throw BlobStoreError.keyLost
+ }
+ func list(_ directory: String) throws -> [String] { try base.list(directory) }
+ func remove(_ path: String) throws { try base.remove(path) }
+}
+
+final class OutboxTests: XCTestCase {
+ func testEnqueueIsDurableOnReturnAndSeqResumesAfterTheHighest() async throws {
+ let disk = MemoryBlobStore()
+ let outbox = Outbox(store: disk, account: "a")
+ let first = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("one"))
+ let second = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("two"))
+ XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000002.entry"])
+
+ let relaunched = Outbox(store: MemoryBlobStore(disk.contents), account: "a")
+ let entries = await relaunched.entries(lane: "doc:1")
+ XCTAssertEqual(entries.map(\.id), [first.id, second.id])
+ XCTAssertEqual(entries.map(\.payload), [bytes("one"), bytes("two")])
+ let third = try await relaunched.enqueue(lane: "doc:1", kind: "update", payload: bytes("three"))
+ XCTAssertEqual(third.seq, 3, "seq is recovered as max + 1")
+ }
+
+ func testLanesAreFIFOAndIndependentAndBatchesAreCutByBytes() async throws {
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ for index in 0..<4 { try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: UInt8(index), count: 100)) }
+ try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b"))
+ let batch = await outbox.sendable(lane: "doc:a", maxBytes: 250)
+ XCTAssertEqual(batch.map(\.seq), [1, 2], "Cut by bytes, in seq order")
+ let oversized = await outbox.sendable(lane: "doc:a", maxBytes: 10)
+ XCTAssertEqual(oversized.map(\.seq), [1], "A batch always carries at least one entry")
+ let other = await outbox.sendable(lane: "doc:b", maxBytes: nil)
+ XCTAssertEqual(other.map(\.payload), [bytes("b")])
+ }
+
+ func testAHoldStopsItsOwnLaneOnly() async throws {
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ let first = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1"))
+ try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("2"))
+ try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("3"))
+ try await outbox.hold([first.id], Hold(.accessLost))
+ let held = await outbox.sendable(lane: "doc:a", maxBytes: nil)
+ XCTAssertEqual(held, [], "Nothing passes a hold: Docs updates depend on the ones before them")
+ let other = await outbox.sendable(lane: "doc:b", maxBytes: nil)
+ XCTAssertEqual(other.count, 1)
+ let summary = await outbox.summary
+ XCTAssertEqual(summary.lanes["doc:a"]?.held, 1)
+ XCTAssertEqual(summary.lanes["doc:a"]?.pending, 1)
+ XCTAssertEqual(summary.lanes["doc:a"]?.firstHold, .accessLost)
+ try await outbox.release(first.id)
+ let released = await outbox.sendable(lane: "doc:a", maxBytes: nil)
+ XCTAssertEqual(released.count, 2)
+ }
+
+ func testReplaceKeepsTheOldestSeqAndTakesANewID() async throws {
+ let disk = MemoryBlobStore()
+ let outbox = Outbox(store: disk, account: "a")
+ var ids: [UUID] = []
+ for text in ["a", "b", "c"] { ids.append(try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes(text)).id) }
+ let after = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("d"))
+ let merged = try await outbox.replace(Array(ids[0...2]), with: bytes("abc"))
+ XCTAssertEqual(merged.seq, 1)
+ XCTAssertFalse(ids.contains(merged.id), "The merged entry gets a new id")
+ XCTAssertEqual(merged.lane, "doc:1")
+ XCTAssertEqual(merged.kind, "update")
+ let lane = await outbox.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("d")], "Lane order is unchanged")
+ XCTAssertEqual(lane.last?.id, after.id)
+ XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000004.entry"])
+ }
+
+ func testReplaceRefusesInFlightHeldGappedAndCrossLaneRuns() async throws {
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ var a: [UUID] = []
+ for index in 0..<4 {
+ a.append(try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: 1, count: 10)).id)
+ // Another lane interleaves in seq; that does not break a run of doc:a.
+ try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)"))
+ }
+ let b = await outbox.entries(lane: "doc:b").map(\.id)
+ let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15)
+ XCTAssertEqual(inFlight.map(\.id), [a[0]])
+
+ await assertThrows(OutboxError.inFlight) { try await outbox.replace([a[0], a[1]], with: Data()) }
+ await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], a[3]], with: Data()) }
+ await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], b[1]], with: Data()) }
+ try await outbox.hold([a[3]], Hold(.tooLarge))
+ await assertThrows(OutboxError.held) { try await outbox.replace([a[2], a[3]], with: Data()) }
+
+ let merged = try await outbox.replace([a[1], a[2]], with: bytes("merged"))
+ XCTAssertEqual(merged.seq, 3, "A run of doc:a skips over doc:b's seqs")
+ try await outbox.ack([a[0]])
+ let lane = await outbox.entries(lane: "doc:a")
+ XCTAssertEqual(lane.map(\.id), [merged.id, a[3]], "Acking the in-flight head never touches the merged entry")
+ }
+
+ func testACrashBetweenReplaceStepsLeavesDuplicatesNeverAGap() async throws {
+ let base = MemoryBlobStore()
+ let setup = Outbox(store: base, account: "a")
+ var ids: [UUID] = []
+ for text in ["a", "b", "c", "d"] { ids.append(try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)).id) }
+
+ let faulting = FaultingBlobStore(MemoryBlobStore(base.contents))
+ let outbox = Outbox(store: faulting, account: "a")
+ try await outbox.load()
+ faulting.crash(after: 1) // step 1 (the write) happens; the first remove does not
+ do {
+ try await outbox.replace(Array(ids[0...2]), with: bytes("abc"))
+ XCTFail("Expected the simulated crash")
+ } catch is SimulatedCrash {}
+
+ let relaunched = Outbox(store: faulting.committed, account: "a")
+ let lane = await relaunched.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("b"), bytes("c"), bytes("d")],
+ "Duplicates only: the merged head, then what it merged, then the rest in order")
+ }
+
+ func testAckDiscardAndPurgeRemoveFiles() async throws {
+ let disk = MemoryBlobStore()
+ let outbox = Outbox(store: disk, account: "a")
+ let one = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1"))
+ try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("2"))
+ try await outbox.enqueue(lane: "doc:c", kind: "u", payload: bytes("3"))
+ try await outbox.ack([one.id, UUID()])
+ try await outbox.discard(lane: "doc:b")
+ XCTAssertEqual(disk.contents.count, 1)
+ try await outbox.purge()
+ XCTAssertEqual(disk.contents.count, 0)
+ let summary = await outbox.summary
+ XCTAssertTrue(summary.isEmpty)
+ }
+
+ func testAnUnavailableStoreIsNeverAnEmptyStore() async throws {
+ let base = MemoryBlobStore()
+ try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("kept"))
+ let faulting = FaultingBlobStore(base)
+ faulting.unavailable = true
+ let outbox = Outbox(store: faulting, account: "a")
+ await assertThrows(BlobStoreError.unavailable) { try await outbox.load() }
+ await assertThrows(BlobStoreError.unavailable) { try await outbox.enqueue(lane: "doc:1", kind: "u", payload: Data()) }
+ let summary = await outbox.summary
+ XCTAssertTrue(summary.unavailable)
+ XCTAssertEqual(base.contents.count, 1, "Nothing was deleted while locked")
+ XCTAssertFalse(faulting.operations.contains { if case .remove = $0 { return true } else { return false } })
+
+ faulting.unavailable = false
+ try await outbox.load()
+ let lane = await outbox.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.payload), [bytes("kept")])
+ let after = await outbox.summary
+ XCTAssertFalse(after.unavailable)
+ }
+
+ func testOneCorruptEntryIsRemovedAloneAndReported() async throws {
+ let base = MemoryBlobStore()
+ let setup = Outbox(store: base, account: "a")
+ for text in ["1", "2", "3"] { try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)) }
+ let outbox = Outbox(store: CorruptingStore(base, corrupt: ["outbox/0000000000000002.entry"]), account: "a")
+ let lane = await outbox.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.payload), [bytes("1"), bytes("3")])
+ XCTAssertEqual(base.contents.count, 2, "Only the corrupt file went")
+ var summary = await outbox.summary
+ XCTAssertTrue(summary.lostUnsent)
+ await outbox.acknowledgeLoss()
+ summary = await outbox.summary
+ XCTAssertFalse(summary.lostUnsent, "Reported once")
+ }
+
+ func testALostKeyEmptiesTheOutboxAndSaysSoOnce() async throws {
+ let base = MemoryBlobStore()
+ try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("gone"))
+ let outbox = Outbox(store: KeyLosingStore(base), account: "a")
+ try await outbox.load()
+ let summary = await outbox.summary
+ XCTAssertTrue(summary.isEmpty)
+ XCTAssertTrue(summary.lostUnsent)
+ try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("new"))
+ let lane = await outbox.entries(lane: "doc:1")
+ XCTAssertEqual(lane.map(\.payload), [bytes("new")])
+ }
+
+ func testChangesDeliverTheCurrentSummaryThenEachChange() async throws {
+ let outbox = Outbox(store: MemoryBlobStore(), account: "a")
+ let changes = await outbox.changes
+ var iterator = changes.makeAsyncIterator()
+ let first = await iterator.next()
+ XCTAssertEqual(first?.pending, 0)
+ try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("x"))
+ let second = await iterator.next()
+ XCTAssertEqual(second?.pending, 1)
+ }
+
+ /// Random enqueues, sends, acks, compactions and crashes. After every
+ /// relaunch: every entry whose enqueue returned and was never acked is
+ /// still there, in order, and each lane's content is a
+ /// duplicates-allowed replay of what was enqueued.
+ func testRandomCrashesNeverLoseOrReorderAnEntry() async throws {
+ var generator = SeededGenerator(seed: 0x6b33)
+ for _ in 0..<150 {
+ var disk = MemoryBlobStore()
+ var faulting = FaultingBlobStore(disk)
+ var outbox = Outbox(store: faulting, account: "a")
+ var expected: [String: [String]] = [:] // lane -> symbols not yet acked, in order
+ var acked: Set<String> = []
+ var symbol = 0
+ for _ in 0..<40 {
+ let lane = ["doc:a", "doc:b"][Int.random(in: 0...1, using: &generator)]
+ if Int.random(in: 0..<12, using: &generator) == 0 { faulting.crash(after: Int.random(in: 0...2, using: &generator)) }
+ do {
+ switch Int.random(in: 0..<10, using: &generator) {
+ case 0..<5:
+ symbol += 1
+ let text = "s\(symbol)"
+ try await outbox.enqueue(lane: lane, kind: "u", payload: bytes(text))
+ expected[lane, default: []].append(text)
+ case 5..<7:
+ let batch = try await outbox.beginSend(lane: lane, maxBytes: 8)
+ guard !batch.isEmpty else { continue }
+ // The server has the batch before the ack starts: a crash part
+ // way through the ack loses nothing the server lacks.
+ for entry in batch {
+ let text = String(decoding: entry.payload, as: UTF8.self)
+ for part in text.split(separator: "+") { acked.insert(String(part)) }
+ }
+ expected[lane] = expected[lane, default: []].filter { !acked.contains($0) }
+ try await outbox.ack(batch.map(\.id))
+ case 7..<9:
+ let run = await outbox.sendable(lane: lane, maxBytes: nil)
+ guard run.count >= 2 else { continue }
+ let take = Array(run.prefix(Int.random(in: 2...run.count, using: &generator)))
+ let joined = take.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: "+")
+ try await outbox.replace(take.map(\.id), with: bytes(joined))
+ default:
+ let batch = try await outbox.beginSend(lane: lane, maxBytes: nil)
+ await outbox.endSend(batch.map(\.id))
+ }
+ } catch is SimulatedCrash {
+ disk = faulting.committed
+ faulting = FaultingBlobStore(disk)
+ outbox = Outbox(store: faulting, account: "a")
+ }
+ // The invariant, checked against a fresh reading of the disk.
+ let reread = Outbox(store: MemoryBlobStore(faulting.committed.contents), account: "a")
+ for (lane, symbols) in expected {
+ let present = await reread.entries(lane: lane).flatMap {
+ String(decoding: $0.payload, as: UTF8.self).split(separator: "+").map(String.init)
+ }
+ let unacked = present.filter { !acked.contains($0) }
+ var seen: [String] = []
+ for item in unacked where seen.last != item && !seen.contains(item) { seen.append(item) }
+ XCTAssertEqual(seen, symbols, "lane \(lane): lost or reordered an entry")
+ }
+ }
+ }
+ }
+}
+
+struct SeededGenerator: RandomNumberGenerator {
+ private var state: UInt64
+ init(seed: UInt64) { state = seed }
+ mutating func next() -> UInt64 {
+ state &+= 0x9E3779B97F4A7C15
+ var z = state
+ z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9
+ z = (z ^ (z >> 27)) &* 0x94D049BB133111EB
+ return z ^ (z >> 31)
+ }
+}
+
+func assertThrows<E: Error & Equatable>(_ expected: E, file: StaticString = #filePath, line: UInt = #line,
+ _ body: () async throws -> some Any) async {
+ do {
+ _ = try await body()
+ XCTFail("Expected \(expected)", file: file, line: line)
+ } catch let error as E {
+ XCTAssertEqual(error, expected, file: file, line: line)
+ } catch {
+ XCTFail("Expected \(expected), got \(error)", file: file, line: line)
+ }
+}
diff --git a/Tests/RastrilloNativeTests/SyncStatusTests.swift b/Tests/RastrilloNativeTests/SyncStatusTests.swift
new file mode 100644
index 0000000..ca3c9c8
--- /dev/null
+++ b/Tests/RastrilloNativeTests/SyncStatusTests.swift
@@ -0,0 +1,71 @@
+import Foundation
+import RastrilloNative
+import XCTest
+
+private func summary(_ lanes: [String: (pending: Int, held: Int, inFlight: Int)], hold: Hold.Reason? = nil) -> OutboxSummary {
+ var summary = OutboxSummary()
+ for (name, counts) in lanes {
+ var lane = OutboxSummary.Lane()
+ lane.pending = counts.pending; lane.held = counts.held; lane.inFlight = counts.inFlight
+ if counts.held > 0 { lane.firstHold = hold ?? .accessLost }
+ summary.lanes[name] = lane
+ }
+ return summary
+}
+
+final class SyncStatusTests: XCTestCase {
+ func testPriorityOrder() {
+ let pending = summary(["doc:a": (2, 0, 0), "doc:b": (1, 0, 0)])
+ let held = summary(["doc:a": (1, 1, 0)], hold: .tooLarge)
+ let flying = summary(["doc:a": (2, 0, 1)])
+
+ // 1. A hold beats everything, even an ended session.
+ XCTAssertEqual(SyncStatus(summary: held, sending: true, sessionEnded: true, offline: true, backingOff: true),
+ .failed(pending: 1, reason: .held(.tooLarge)))
+ // A local failure is failed too.
+ XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false, localFailure: .cannotSaveOnDevice),
+ .failed(pending: 2, reason: .cannotSaveOnDevice))
+ // 2. Session ended.
+ XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: true, offline: true, backingOff: true), .needsSignIn(pending: 2))
+ // 3. Nothing to send.
+ XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: false, backingOff: false), .saved)
+ // 4. In flight.
+ XCTAssertEqual(SyncStatus(summary: flying, sending: true, sessionEnded: false, offline: true, backingOff: false), .saving)
+ // 5. Offline.
+ XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: true, backingOff: true), .offline(pending: 2))
+ // 6. Backing off.
+ XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: true), .retrying(pending: 2))
+ // Sendable, online, not yet started: about to save, never "saved".
+ XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false), .saving)
+ }
+
+ func testOfflineWithNothingPendingIsSilentlySaved() {
+ let status = SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: true, backingOff: false)
+ XCTAssertEqual(status, .saved, "\"Offline · 0 pending\" must never appear")
+ XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: true, offline: true, backingOff: false), .saved,
+ "\"Sign in to send 0 pending\" must never appear either")
+ }
+
+ func testNCountsLanesByDefaultAndWhatTheAppChooses() {
+ let lanes = summary(["doc:a": (5, 0, 0), "doc:b": (1, 0, 0)])
+ XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false), .offline(pending: 2))
+ XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false, count: { $0.pending }),
+ .offline(pending: 6))
+ }
+
+ func testSaveStateMappingStickinessAndAnnouncements() {
+ XCTAssertEqual(SyncStatus.saved.saveState, .saved)
+ XCTAssertEqual(SyncStatus.saving.saveState, .saving)
+ XCTAssertEqual(SyncStatus.offline(pending: 1).saveState, .retrying, "The Slides precedent: offline is retrying")
+ XCTAssertEqual(SyncStatus.retrying(pending: 1).saveState, .retrying)
+ XCTAssertEqual(SyncStatus.needsSignIn(pending: 1).saveState, .failed)
+ XCTAssertEqual(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).saveState, .failed)
+ XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isSticky)
+ XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .cannotSaveOnDevice).isSticky)
+ XCTAssertFalse(SyncStatus.offline(pending: 1).isSticky)
+ XCTAssertFalse(SyncStatus.saving.isAnnounced, "A saving flicker is never announced")
+ XCTAssertTrue(SyncStatus.offline(pending: 1).isAnnounced)
+ XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isAnnounced)
+ XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).isAnnounced)
+ }
+}
diff --git a/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift
new file mode 100644
index 0000000..d420876
--- /dev/null
+++ b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift
@@ -0,0 +1,48 @@
+import Foundation
+import RastrilloNative
+import RastrilloTestSupport
+import XCTest
+
+final class FaultingBlobStoreTests: XCTestCase {
+ func testACrashFreezesTheCommittedViewAndEverythingAfterFails() throws {
+ let store = FaultingBlobStore(crashAt: 2)
+ try store.write(Data("a".utf8), to: "x/1")
+ try store.write(Data("b".utf8), to: "x/2")
+ XCTAssertThrowsError(try store.write(Data("c".utf8), to: "x/3")) { XCTAssertEqual($0 as? SimulatedCrash, SimulatedCrash(operation: 2)) }
+ XCTAssertThrowsError(try store.read("x/1"), "A dead process does nothing more") { XCTAssertTrue($0 is SimulatedCrash) }
+ XCTAssertTrue(store.hasCrashed)
+ XCTAssertEqual(store.committed.contents.keys.sorted(), ["x/1", "x/2"], "The crashed write never happened")
+ XCTAssertEqual(store.operations, [.write("x/1"), .write("x/2")])
+ }
+
+ func testCrashAfterCountsFromNow() throws {
+ let store = FaultingBlobStore()
+ try store.write(Data("a".utf8), to: "x/1")
+ store.crash(after: 1)
+ try store.remove("x/1")
+ XCTAssertThrowsError(try store.list("x"))
+ XCTAssertEqual(store.committed.contents, [:])
+ }
+
+ func testUnavailableAnswersEveryOperationAndChangesNothing() throws {
+ let store = FaultingBlobStore()
+ try store.write(Data("a".utf8), to: "x/1")
+ store.unavailable = true
+ XCTAssertThrowsError(try store.read("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) }
+ XCTAssertThrowsError(try store.remove("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) }
+ XCTAssertThrowsError(try store.list("x")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) }
+ store.unavailable = false
+ XCTAssertEqual(try store.read("x/1"), Data("a".utf8))
+ }
+
+ func testMemoryStoreListsOneLevelAndRemovesDirectories() throws {
+ let store = MemoryBlobStore()
+ try store.write(Data(), to: "outbox/1.entry")
+ try store.write(Data(), to: "outbox/2.entry")
+ try store.write(Data(), to: "cache/docs/a/replica")
+ XCTAssertEqual(try store.list("outbox"), ["1.entry", "2.entry"])
+ XCTAssertEqual(try store.list("cache/docs"), [])
+ try store.remove("cache")
+ XCTAssertEqual(store.contents.keys.sorted(), ["outbox/1.entry", "outbox/2.entry"])
+ }
+}