oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import CryptoKit
3import RastrilloApple
4import RastrilloNative
5import RastrilloTestSupport
6import XCTest
7@testable import DocsKit
8
9/// Uses the production WASM replica: a lost response really integrates bytes.
10private actor IntegratingServer: NativeTransport {
11 nonisolated let origin = URL(string: "http://localhost:8099")!
12 var creationTokens: Set<String> = []
13 func documentCount() -> Int { creationTokens.count }
14 var online = true
15 var title = "Document"
16 var titleFailures = 0
17 var titleDelay = false
18 var updateDelay = false
19 var updateRequests = 0
20 func delayUpdates() { updateDelay = true }
21 func updateRequestCount() -> Int { updateRequests }
22 var titleRequests = 0
23 func delayTitles() { titleDelay = true }
24 func titleRequestCount() -> Int { titleRequests }
25 func failTitles(_ count: Int) { titleFailures = count }
26 func savedTitle() -> String { title }
27
28 var loseNextResponse = false
29 var sequence: Int64 = 0
30 var rows: [Int64: Data] = [:]
31 var listeners: [UUID: AsyncStream<Int64>.Continuation] = [:]
32 func setOnline(_ value: Bool) { online = value }
33 let replica = CRDTReplica(guid: "offline-property")
34 var accepted: [UUID: Data] = [:]
35 func open(_ initial: Data) async throws { _ = try await replica.open(update: initial) }
36 func accept(_ entries: [OutboxEntry]) async throws {
37 for entry in entries { _ = try await replica.apply(entry.payload); accepted[entry.id] = entry.payload }
38 }
39 func snapshot() async throws -> Data { try await replica.persistentState().state }
40 func text() async throws -> String { try await replica.projection().children.map(\.plainText).joined() }
41 func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse {
42 guard online else { throw NativeClientError.offline(.notConnectedToInternet) }
43 if method == "POST", path == "/documents", let body {
44 let token = URLComponents(string: "?" + String(decoding: body, as: UTF8.self))?.queryItems?.first(where: { $0.name == "creation_token" })?.value ?? ""
45 creationTokens.insert(token)
46 return NativeResponse(data: Data(#"{"sqid":"doc","title":"Document"}"#.utf8), status: 201, headers: [:])
47 }
48 if method == "POST", path.hasSuffix("/title"), let body {
49 titleRequests += 1
50 if titleDelay { try await Task.sleep(for: .milliseconds(200)) }
51 if titleFailures > 0 { titleFailures -= 1; throw NativeClientError.server(status: 503) }
52 title = URLComponents(string: "?" + String(decoding: body, as: UTF8.self))?.queryItems?.first(where: { $0.name == "title" })?.value ?? ""
53 return NativeResponse(data: Data(), status: 200, headers: [:])
54 }
55 if path.hasPrefix("/?") {
56 return NativeResponse(data: Data(#"{"documents":[],"can_create":true}"#.utf8), status: 200, headers: [:])
57 }
58 if method == "POST", path.hasSuffix("/updates"), let body {
59 updateRequests += 1
60 if updateDelay { try await Task.sleep(for: .milliseconds(300)) }
61 _ = try await replica.apply(body); sequence += 1
62 rows[sequence] = body
63 for listener in listeners.values { listener.yield(sequence) }
64 if loseNextResponse { loseNextResponse = false; throw NativeClientError.offline(.networkConnectionLost) }
65 return NativeResponse(data: Data(), status: 204, headers: [:])
66 }
67 if path.contains("/updates") {
68 let since = URLComponents(string: path)?.queryItems?.first(where: { $0.name == "since" })?.value.flatMap(Int64.init) ?? 0
69 let data: Data
70 if since > 0 {
71 let updates = rows.keys.sorted().filter { $0 > since }.compactMap { rows[$0] }
72 data = updates.isEmpty ? Data() : try await replica.mergeUpdates(updates)
73 } else { data = try await snapshot() }
74 return NativeResponse(data: data, status: 200, headers: ["Docs-Seq": String(sequence)])
75 }
76 let detail = DocumentDetail(sqid: "doc", title: title, guid: "offline-property", can_write: true, can_share: false)
77 return NativeResponse(data: try JSONEncoder().encode(detail), status: 200, headers: [:])
78 }
79 func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws {
80 guard online else { throw NativeClientError.offline(.notConnectedToInternet) }
81 let id = UUID(), events = AsyncStream<Int64>.makeStream(bufferingPolicy: .bufferingNewest(1))
82 listeners[id] = events.continuation
83 defer { listeners.removeValue(forKey: id) }
84 try await onOpen(); try await onLine("data: \(sequence)")
85 for await sequence in events.stream { try Task.checkCancellation(); try await onLine("data: \(sequence)") }
86 }
87 func download(_ path: String, to destination: URL) async throws { throw NativeClientError.notFound }
88
89 func contains(_ id: UUID) -> Bool { accepted[id] != nil }
90}
91private final class MetadataLockStore: BlobStore, @unchecked Sendable {
92 let base = MemoryBlobStore()
93 private let lock = NSLock()
94 private var locked = true
95 func unlock() { lock.withLock { locked = false } }
96 func read(_ path: String) throws -> Data? { try base.read(path) }
97 func list(_ path: String) throws -> [String] { try base.list(path) }
98 func remove(_ path: String) throws { try base.remove(path) }
99 func write(_ bytes: Data, to path: String) throws {
100 if path.hasSuffix("/meta"), lock.withLock({ locked }) { throw BlobStoreError.unavailable }
101 try base.write(bytes, to: path)
102 }
103}
104private struct OfflineRandom: RandomNumberGenerator {
105 var seed: UInt64
106 mutating func next() -> UInt64 { seed = seed &* 6364136223846793005 &+ 1442695040888963407; return seed }
107}
108@MainActor final class OfflineDurabilityTests: XCTestCase {
109 private func initial() async throws -> Data {
110 let seed = CRDTReplica(guid: "offline-property")
111 _ = try await seed.open(update: Data())
112 let (update, _) = try await seed.command(["op": .string("insert_element"), "at": .number(0), "type": .string("paragraph")])
113 await seed.close(); return update
114 }
115 private func edit(_ replica: CRDTReplica, _ text: String) async throws -> Data {
116 let projection = try await replica.projection()
117 let at = projection.children.map(\.plainText).joined().utf16.count
118 return try await replica.command(["op": .string("display_edit"), "at": .number(Double(at)), "length": .number(0), "text": .string(text)]).0
119 }
120 func testMissingOrCorruptMetadataHoldsOnlyItsLane() async throws {
121 for damage in 0..<3 {
122 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
123 defer { try? FileManager.default.removeItem(at: root) }
124 let disk = NativeSealedStore(root: root, accountID: "a", keys: ScriptedKeyProvider())
125 let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied))
126 while !connectivity.isPathSatisfied { await Task.yield() }
127 let transport = ScriptedTransport([])
128 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: connectivity, transport: { transport })
129 if damage > 0 { try disk.write(Data("invalid metadata".utf8), to: "cache/docs/a/meta") }
130 if damage == 2 { try Data([0]).write(to: root.appendingPathComponent("cache/docs/a/meta")) }
131 try await account.outbox.enqueue(lane: "doc:a", kind: "metadata", payload: Data())
132 let detail = DocumentDetail(sqid: "b", title: "B", guid: "b", can_write: true, can_share: false)
133 try account.saveMetadata(DocsOfflineMetadata(detail: detail), key: "b")
134 try await account.outbox.enqueue(lane: "doc:b", kind: "metadata", payload: Data())
135 await account.drainer.drain()
136 let broken = await account.outbox.entries(lane: "doc:a")
137 let good = await account.outbox.entries(lane: "doc:b")
138 XCTAssertEqual(broken.first?.hold, Hold(.rejected("metadata")))
139 XCTAssertTrue(good.isEmpty, "Missing metadata must not block another document")
140 }
141 }
142
143 func testHeldMetadataMarkerDoesNotMultiplyOnFlush() async throws {
144 let account = DocsOfflineAccount(store: MemoryBlobStore(), accountID: "a", connectivity: NativeConnectivity(source: ScriptedPathSource(.unsatisfied)), transport: { ScriptedTransport([]) })
145 let detail = DocumentDetail(sqid: "doc", title: "Title", guid: "doc", can_write: true, can_share: false)
146 try account.saveMetadata(DocsOfflineMetadata(detail: detail, pendingTitle: "Rename"), key: "doc")
147 try await account.ensureMetadataEntry("doc")
148 let original = await account.outbox.entries(lane: "doc:doc")
149 try await account.outbox.hold(original.map(\.id), Hold(.accessLost))
150 for _ in 0..<4 { try await account.ensureMetadataEntry("doc") }
151 let entries = await account.outbox.entries(lane: "doc:doc")
152 XCTAssertEqual(entries.map(\.id), original.map(\.id))
153 }
154
155 func testDurableOfflineLaneUsesSyncStatusWithoutAnErrorMessage() async throws {
156 let server = IntegratingServer(); try await server.open(initial())
157 let path = ScriptedPathSource(.satisfied)
158 let connectivity = NativeConnectivity(source: path)
159 let account = DocsOfflineAccount(store: MemoryBlobStore(), accountID: "a", connectivity: connectivity, transport: { server })
160 let model = DocsModel(transport: { server }, connectivity: connectivity, offlineAccount: account)
161 await model.open("doc"); _ = await model.saveAll()
162 path.send(.unsatisfied)
163 while connectivity.isPathSatisfied { await Task.yield() }
164 model.didRender(model.projectionGeneration)
165 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string("Offline")]))
166 while model.hasPendingInput { await Task.yield() }
167 await model.flush()
168 XCTAssertNil(model.errorKey, "A durable pending lane does not require keeping this window open")
169 XCTAssertEqual(model.syncStatus, .offline(pending: 1))
170 await model.stop(); account.drainer.stop()
171 }
172
173 func testWriterBetweenCommandAndEnqueueCannotPersistTheCommand() async throws {
174 let disk = MemoryBlobStore(), initial = try await initial()
175 let replica = CRDTReplica(guid: "offline-property")
176 let lane = DocsPersistence(store: disk, outbox: Outbox(store: disk, account: "a"), key: "doc", replica: replica)
177 try await lane.reopen(fallback: initial, sequence: 17)
178 let wroteInitial = try await lane.persist()
179 XCTAssertTrue(wroteInitial)
180 let before = disk.contents[lane.path]
181 let update = try await edit(replica, "A")
182 let premature = try await lane.persist()
183 XCTAssertFalse(premature, "I1a: command returned, but enqueue has not")
184 XCTAssertEqual(disk.contents[lane.path], before)
185 try await lane.enqueue(update)
186 let wrote = try await lane.persist()
187 XCTAssertTrue(wrote)
188 let saved = try JSONDecoder().decode(PersistedReplica.self, from: XCTUnwrap(disk.contents[lane.path]))
189 XCTAssertEqual(saved.sequence, 17)
190 XCTAssertFalse(saved.state.isEmpty)
191 }
192 func testReopenAppliesTheWholeLaneBeforePublishingAndDoesNotDuplicate() async throws {
193 let disk = MemoryBlobStore(), initial = try await initial()
194 let outbox = Outbox(store: disk, account: "a"), replica = CRDTReplica(guid: "offline-property")
195 let lane = DocsPersistence(store: disk, outbox: outbox, key: "draft-token", replica: replica)
196 try await lane.reopen(fallback: initial, sequence: 0)
197 try await lane.enqueue(edit(replica, "A"))
198 _ = try await lane.persist()
199 try await lane.enqueue(edit(replica, "B"))
200 for _ in 0..<2 {
201 let reopened = CRDTReplica(guid: "offline-property")
202 let next = DocsPersistence(store: disk, outbox: Outbox(store: disk, account: "a"), key: "draft-token", replica: reopened)
203 try await next.reopen(fallback: Data(), sequence: 0)
204 let text = try await reopened.projection().children.map(\.plainText).joined()
205 XCTAssertEqual(text, "AB", "I1b: replica contains A; lane contains A and B")
206 }
207 try await lane.discard()
208 XCTAssertNil(disk.contents[lane.path], "Discard cannot leave stranded local text in the replica")
209 let entries = await outbox.entries(lane: lane.lane)
210 XCTAssertTrue(entries.isEmpty)
211 }
212 /// Deterministic generated histories, plus an exhaustive crash sweep at
213 /// replacement's write and every delete. Every assertion reads committed
214 /// bytes through a fresh outbox/replica, not the live writer's memory.
215 func testGeneratedCrashHistoriesPreserveI1AndConverge() async throws {
216 let initial = try await initial()
217 for seed in 1...12 {
218 var random = OfflineRandom(seed: UInt64(seed))
219 var disk = FaultingBlobStore()
220 var outbox = Outbox(store: disk, account: "a")
221 var replica = CRDTReplica(guid: "offline-property")
222 var lane = DocsPersistence(store: disk, outbox: outbox, key: "doc", replica: replica)
223 try await lane.reopen(fallback: initial, sequence: 0)
224 let server = IntegratingServer(); try await server.open(initial)
225 var durableText = ""
226 var flying: [OutboxEntry] = []
227 var symbol = 0
228 for step in 0..<60 {
229 if step % 4 == 0 { disk.crash(after: Int.random(in: 0...10, using: &random)) }
230 do {
231 switch Int.random(in: 0..<8, using: &random) {
232 case 0, 1:
233 symbol += 1
234 let text = "[\(symbol)]"
235 let update = try await edit(replica, text)
236 let premature = try await lane.persist()
237 XCTAssertFalse(premature, "Writer tick between command and enqueue")
238 try await lane.enqueue(update); durableText += text
239 case 2: _ = try await lane.persist()
240 case 3, 4:
241 if flying.isEmpty { flying = try await outbox.beginSend(lane: lane.lane, maxBytes: 80) }
242 try await server.accept(flying)
243 if step % 2 == 0 {
244 for entry in flying { let has = await server.contains(entry.id); XCTAssertTrue(has) }
245 try await outbox.ack(flying.map(\.id))
246 } // otherwise the response is lost: server has it; disk keeps it
247 await outbox.endSend(flying.map(\.id)); flying = []
248 case 5:
249 let entries = await outbox.entries(lane: lane.lane)
250 if entries.count >= 2 {
251 if step % 2 == 0 {
252 flying = try await outbox.beginSend(lane: lane.lane, maxBytes: 1)
253 do { try await outbox.replace(entries.map(\.id), with: replica.mergeUpdates(entries.map(\.payload))); XCTFail("In-flight replacement must throw") }
254 catch OutboxError.inFlight {}
255 await outbox.endSend(flying.map(\.id)); flying = []
256 } else { try await lane.compact() }
257 }
258 case 6:
259 // A crash before any subsequent store op, including a
260 // writer tick or replacement's first/second step.
261 disk.crash(after: Int.random(in: 0...2, using: &random))
262 let entries = await outbox.entries(lane: lane.lane)
263 if entries.count >= 2 { try await outbox.replace(entries.map(\.id), with: replica.mergeUpdates(entries.map(\.payload))) }
264 else { _ = try await lane.persist() }
265 default:
266 for entry in try await outbox.beginSend(lane: lane.lane, maxBytes: nil) {
267 try await server.accept([entry]); try await outbox.ack([entry.id])
268 }
269 }
270 } catch is SimulatedCrash { }
271 // Reopen after *every* event. This simultaneously checks the
272 // missing-lane and duplicate-text cases at each crash boundary.
273 let frozen = disk.committed
274 let checked = CRDTReplica(guid: "offline-property")
275 let checkLane = DocsPersistence(store: frozen, outbox: Outbox(store: frozen, account: "a"), key: "doc", replica: checked)
276 try await checkLane.reopen(fallback: server.snapshot(), sequence: 0)
277 // Catch up with already acknowledged edits if the replica writer
278 // lagged behind ack; real reopen performs the same server catch-up.
279 _ = try await checked.apply(server.snapshot())
280 let recovered = try await checked.projection().children.map(\.plainText).joined()
281 XCTAssertEqual(recovered, durableText, "seed \(seed), event \(step): I1b / no duplicates")
282 if let data = frozen.contents[lane.path] {
283 let saved = try JSONDecoder().decode(PersistedReplica.self, from: data)
284 let proof = CRDTReplica(guid: "offline-property")
285 _ = try await proof.open(update: server.snapshot())
286 for entry in await Outbox(store: frozen, account: "a").entries(lane: lane.lane) { _ = try await proof.apply(entry.payload) }
287 let prior = try await proof.projection().children.map(\.plainText)
288 _ = try await proof.apply(saved.state)
289 let after = try await proof.projection().children.map(\.plainText)
290 XCTAssertEqual(prior, after, "I1a: replica contains no stranded local edit")
291 await proof.close()
292 }
293 await checked.close()
294 if disk.hasCrashed {
295 await replica.close()
296 disk = FaultingBlobStore(frozen); outbox = Outbox(store: disk, account: "a")
297 replica = CRDTReplica(guid: "offline-property")
298 lane = DocsPersistence(store: disk, outbox: outbox, key: "doc", replica: replica)
299 try await lane.reopen(fallback: server.snapshot(), sequence: 0)
300 _ = try await replica.apply(server.snapshot())
301 flying = []
302 }
303 }
304 // Reconnect and fully drain, with the real integrating replica.
305 let recovered = Outbox(store: disk.committed, account: "a")
306 let entries = await recovered.entries(lane: lane.lane)
307 try await server.accept(entries); try await recovered.ack(entries.map(\.id))
308 let text = try await server.text()
309 XCTAssertEqual(text, durableText, "seed \(seed): convergence after drain")
310 await replica.close()
311 }
312 }
313 func testEveryReplaceCrashBoundaryReopensWithoutLossOrDuplicateText() async throws {
314 let initial = try await initial()
315 let base = MemoryBlobStore(), source = CRDTReplica(guid: "offline-property")
316 let setup = DocsPersistence(store: base, outbox: Outbox(store: base, account: "a"), key: "doc", replica: source)
317 try await setup.reopen(fallback: initial, sequence: 0)
318 _ = try await setup.persist()
319 for text in ["A", "B", "C"] { try await setup.enqueue(edit(source, text)) }
320 for crashAfter in 0...3 {
321 let disk = FaultingBlobStore(MemoryBlobStore(base.contents))
322 let outbox = Outbox(store: disk, account: "a")
323 try await outbox.load()
324 let entries = await outbox.entries(lane: "doc:doc")
325 let merged = try await source.mergeUpdates(entries.map(\.payload))
326 disk.crash(after: crashAfter)
327 do { try await outbox.replace(entries.map(\.id), with: merged) }
328 catch is SimulatedCrash { }
329 let rebuilt = CRDTReplica(guid: "offline-property")
330 let frozen = disk.committed
331 let recovered = DocsPersistence(store: frozen, outbox: Outbox(store: frozen, account: "a"), key: "doc", replica: rebuilt)
332 try await recovered.reopen(fallback: Data(), sequence: 0)
333 let text = try await rebuilt.projection().children.map(\.plainText).joined()
334 XCTAssertEqual(text, "ABC", "Crash at replace operation \(crashAfter)")
335 await rebuilt.close()
336 }
337 await source.close()
338 }
339
340 func testModelReopensOfflineAndDrainsToIntegratingTransport() async throws {
341 let server = IntegratingServer(); try await server.open(initial())
342 let disk = FaultingBlobStore()
343 let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied))
344 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: connectivity, transport: { server })
345 let model = DocsModel(transport: { server }, connectivity: connectivity, offlineAccount: account)
346 await model.open("doc")
347 XCTAssertNotNil(model.projection)
348 await server.setOnline(false)
349 model.didRender(model.projectionGeneration)
350 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string("Offline edit")]))
351 while model.hasPendingInput { try await Task.sleep(for: .milliseconds(5)) }
352 XCTAssertTrue(model.hasUnsentChanges)
353 // Kill before the two-second writer. Freeze this process's store so
354 // its tasks cannot mutate the new process's committed view.
355 account.drainer.stop(); disk.crash(after: 0)
356 let frozen = disk.committed
357 let nextAccount = DocsOfflineAccount(store: frozen, accountID: "a", connectivity: connectivity, transport: { server })
358 let reopened = DocsModel(transport: { server }, connectivity: connectivity, offlineAccount: nextAccount)
359 await reopened.open("doc")
360 XCTAssertEqual(reopened.projection?.children.map(\.plainText).joined(), "Offline edit")
361 await server.setOnline(true)
362 await reopened.retry()
363 let deadline = ContinuousClock.now.advanced(by: .seconds(3))
364 while reopened.hasUnsavedChanges && .now < deadline { await reopened.flush(); try await Task.sleep(for: .milliseconds(10)) }
365 XCTAssertFalse(reopened.hasUnsavedChanges)
366 let text = try await server.text()
367 XCTAssertEqual(text, "Offline edit")
368 await reopened.stop(); nextAccount.drainer.stop()
369 }
370
371 func testUnlockRetriesFailedUpdateAndResumesAlreadyQueuedCommands() async throws {
372 let server = IntegratingServer(); try await server.open(initial())
373 let disk = FaultingBlobStore()
374 let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied))
375 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: connectivity, transport: { server })
376 let model = DocsModel(transport: { server }, connectivity: connectivity, offlineAccount: account)
377 await model.open("doc")
378 await server.setOnline(false)
379 disk.unavailable = true
380 for text in ["A", "B"] {
381 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string(text)]))
382 }
383 let deadline = ContinuousClock.now.advanced(by: .seconds(2))
384 while !model.inputPaused && .now < deadline { try await Task.sleep(for: .milliseconds(5)) }
385 XCTAssertTrue(model.inputPaused)
386 disk.unavailable = false
387 await model.retry()
388 let resumed = ContinuousClock.now.advanced(by: .seconds(2))
389 while model.hasPendingInput && .now < resumed { try await Task.sleep(for: .milliseconds(5)) }
390 XCTAssertFalse(model.hasPendingInput, "Retry must resume the queued second command")
391 XCTAssertEqual(model.projection?.children.map(\.plainText).joined(), "BA")
392 await model.persistReplica()
393 XCTAssertFalse(model.hasUnpersistedInput)
394 await model.discardAll(); account.drainer.stop()
395 }
396
397 func testDurableTitleSurvivesReopenBeforeFirstReplicaWrite() async throws {
398 let server = IntegratingServer(); try await server.open(initial())
399 let disk = MemoryBlobStore()
400 let path = ScriptedPathSource(.unsatisfied)
401 let network = NativeConnectivity(source: path)
402 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
403 let detail = DocumentDetail(sqid: "doc", title: "Local title", guid: "offline-property", can_write: true, can_share: false)
404 try account.saveMetadata(DocsOfflineMetadata(detail: detail, pendingTitle: "Local title"), key: "doc")
405 let reopened = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
406 await reopened.open("doc")
407 XCTAssertEqual(reopened.detail?.title, "Local title")
408 XCTAssertEqual(try account.metadata("doc")?.pendingTitle, "Local title")
409 await reopened.discardAll(); account.drainer.stop()
410 }
411
412 func testTitleOnlyTransientFailureRetriesWithoutAnotherEdit() async throws {
413 let server = IntegratingServer(); try await server.open(initial())
414 let network = NativeConnectivity(source: ScriptedPathSource(.satisfied))
415 let account = DocsOfflineAccount(store: MemoryBlobStore(), accountID: "a", connectivity: network, transport: { server })
416 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
417 await model.open("doc")
418 try await Task.sleep(for: .milliseconds(100))
419 await server.failTitles(1)
420 XCTAssertTrue(model.rename("Retry title"))
421 let deadline = ContinuousClock.now.advanced(by: .seconds(3))
422 while await server.savedTitle() != "Retry title", .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
423 let title = await server.savedTitle()
424 XCTAssertEqual(title, "Retry title")
425 await model.discardAll(); account.drainer.stop()
426 }
427
428 func testHomeOnlyReplayPreservesAcknowledgedTextForOfflineReopen() async throws {
429 let seed = try await initial(), disk = MemoryBlobStore()
430 let server = IntegratingServer(); try await server.open(seed)
431 let network = NativeConnectivity(source: ScriptedPathSource(.satisfied))
432 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
433 let detail = DocumentDetail(sqid: "doc", title: "Document", guid: "offline-property", can_write: true, can_share: false)
434 try account.saveMetadata(DocsOfflineMetadata(detail: detail), key: "doc")
435 let replica = CRDTReplica(guid: detail.guid)
436 let lane = DocsPersistence(store: disk, outbox: account.outbox, key: "doc", replica: replica)
437 try await lane.reopen(fallback: seed, sequence: 0)
438 _ = try await lane.persist()
439 try await lane.enqueue(edit(replica, "Home replay"))
440 await replica.close()
441 // Home loads the list; it never opens the document's editor.
442 let home = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
443 await home.load()
444 let deadline = ContinuousClock.now.advanced(by: .seconds(3))
445 while !(await account.outbox.summary).isEmpty, .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
446 let serverText = try await server.text()
447 XCTAssertEqual(serverText, "Home replay")
448 let summary = await account.outbox.summary
449 XCTAssertTrue(summary.isEmpty)
450 account.drainer.stop(); await server.setOnline(false)
451 let next = DocsOfflineAccount(store: disk, accountID: "a", connectivity: NativeConnectivity(source: ScriptedPathSource(.unsatisfied)), transport: { server })
452 let reopened = DocsModel(transport: { server }, offlineAccount: next)
453 await reopened.open("doc")
454 XCTAssertEqual(reopened.projection?.children.map(\.plainText).joined(), "Home replay")
455 await reopened.discardAll(); next.drainer.stop()
456 }
457
458 func testRenameDuringTitleRequestSendsTheLatestTitle() async throws {
459 let server = IntegratingServer(); try await server.open(initial())
460 let network = NativeConnectivity(source: ScriptedPathSource(.satisfied))
461 let account = DocsOfflineAccount(store: MemoryBlobStore(), accountID: "a", connectivity: network, transport: { server })
462 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
463 await model.open("doc")
464 try await Task.sleep(for: .milliseconds(100))
465 await server.delayTitles()
466 XCTAssertTrue(model.rename("First"))
467 let started = ContinuousClock.now.advanced(by: .seconds(2))
468 while await server.titleRequestCount() == 0, .now < started { try await Task.sleep(for: .milliseconds(5)) }
469 XCTAssertTrue(model.rename("Latest"))
470 let deadline = ContinuousClock.now.advanced(by: .seconds(3))
471 while await server.savedTitle() != "Latest", .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
472 let title = await server.savedTitle()
473 XCTAssertEqual(title, "Latest")
474 XCTAssertNil(try account.metadata("doc")?.pendingTitle)
475 await model.discardAll(); account.drainer.stop()
476 }
477
478 func testAttachedDraftMetadataRepairsMissingIndexAtEveryWriteBoundary() async throws {
479 let base = MemoryBlobStore()
480 let network = NativeConnectivity(source: ScriptedPathSource(.unsatisfied))
481 let original = DocsOfflineAccount(store: base, accountID: "a", connectivity: network, transport: { nil })
482 let draft = DocumentDetail(sqid: "draft-token", title: "Draft", guid: "g", can_write: true, can_share: false)
483 try original.saveMetadata(DocsOfflineMetadata(detail: draft, pendingTitle: "Renamed", draftToken: "token"), key: "draft-token")
484 let attached = DocumentDetail(sqid: "real", title: "Renamed", guid: "g", can_write: true, can_share: false)
485 for crashAfter in 0...8 {
486 let disk = FaultingBlobStore(MemoryBlobStore(base.contents))
487 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { nil })
488 disk.crash(after: crashAfter)
489 do { try account.saveMetadata(DocsOfflineMetadata(detail: attached, pendingTitle: "Renamed"), key: "draft-token") }
490 catch is SimulatedCrash { }
491 let next = DocsOfflineAccount(store: disk.committed, accountID: "a", connectivity: network, transport: { nil })
492 let metadata = try XCTUnwrap(next.metadata("draft-token"))
493 if metadata.draftToken == nil {
494 XCTAssertEqual(try next.key(for: "real"), "draft-token", "Crash boundary \(crashAfter): discover attached bytes at their original sealed path")
495 }
496 XCTAssertEqual(metadata.pendingTitle, "Renamed")
497 }
498 }
499
500 func testConcurrentReplicaCommitsMergeAndDiscardInvalidatesOldCaptures() async throws {
501 let disk = MemoryBlobStore(), cache = DocsReplicaCache(store: disk), seed = try await initial()
502 let a = CRDTReplica(guid: "offline-property"), b = CRDTReplica(guid: "offline-property")
503 _ = try await a.open(update: seed); _ = try await b.open(update: seed)
504 let outbox = Outbox(store: disk, account: "a")
505 try await outbox.enqueue(lane: "doc:doc", kind: "update", payload: edit(a, "A"))
506 try await outbox.enqueue(lane: "doc:doc", kind: "update", payload: edit(b, "B"))
507 let first = PersistedReplica(format: 1, sequence: 2, guid: "offline-property", savedAt: Date(), state: try await a.persistentState().state)
508 let second = PersistedReplica(format: 1, sequence: 3, guid: "offline-property", savedAt: Date(), state: try await b.persistentState().state)
509 async let writeA = cache.commit(first, key: "doc", generation: 0)
510 async let writeB = cache.commit(second, key: "doc", generation: 0)
511 _ = try await (writeA, writeB)
512 // An older editor capture must not erase the headless sender's state.
513 try await cache.commit(first, key: "doc", generation: 0)
514 let saved = try JSONDecoder().decode(PersistedReplica.self, from: XCTUnwrap(disk.contents["cache/docs/doc/replica"]))
515 let checked = CRDTReplica(guid: "offline-property")
516 let text = try await checked.open(update: saved.state).children.map(\.plainText).joined()
517 XCTAssertEqual(text.count, 2); XCTAssertTrue(text.contains("A")); XCTAssertTrue(text.contains("B"))
518 XCTAssertEqual(saved.sequence, 3)
519 cache.invalidate("doc"); try disk.remove("cache/docs/doc/replica")
520 do { try await cache.commit(first, key: "doc", generation: 0); XCTFail("A discarded capture must not write") }
521 catch is CancellationError { }
522 XCTAssertNil(disk.contents["cache/docs/doc/replica"])
523 await a.close(); await b.close(); await checked.close()
524 }
525
526 func testEveryDiscardCrashBoundaryLeavesNoStrandedReplica() async throws {
527 let base = MemoryBlobStore(), replica = CRDTReplica(guid: "offline-property")
528 let setup = DocsPersistence(store: base, outbox: Outbox(store: base, account: "a"), key: "doc", replica: replica)
529 try await setup.reopen(fallback: initial(), sequence: 0)
530 for text in ["A", "B"] { try await setup.enqueue(edit(replica, text)) }
531 _ = try await setup.persist()
532 for crashAfter in 0...6 {
533 let disk = FaultingBlobStore(MemoryBlobStore(base.contents))
534 let queue = Outbox(store: disk, account: "a")
535 try await queue.load()
536 let lane = DocsPersistence(store: disk, outbox: queue, key: "doc", replica: replica)
537 disk.crash(after: crashAfter)
538 do { try await lane.discard() } catch is SimulatedCrash { }
539 let frozen = disk.committed
540 let entries = await Outbox(store: frozen, account: "a").entries(lane: "doc:doc")
541 if frozen.contents[lane.path] != nil {
542 XCTAssertEqual(entries.count, 2, "Crash \(crashAfter): no unsent text may survive without its complete lane")
543 }
544 }
545 await replica.close()
546 }
547
548 func testLockedRenameIsRetriedBeforeLocalFailureClears() async throws {
549 let server = IntegratingServer(); try await server.open(initial())
550 let disk = FaultingBlobStore(), network = NativeConnectivity(source: ScriptedPathSource(.unsatisfied))
551 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
552 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
553 await model.open("doc")
554 disk.unavailable = true
555 model.renameInput = "Keep this title"
556 XCTAssertFalse(model.commitRename())
557 XCTAssertFalse(model.commitRename(), "Equality with the failed in-memory title is not a durable commit")
558 disk.unavailable = false
559 await model.retry()
560 XCTAssertEqual(try account.metadata("doc")?.pendingTitle, "Keep this title")
561 XCTAssertTrue(model.commitRename())
562 await model.persistReplica()
563 XCTAssertEqual(try account.metadata("doc")?.pendingTitle, "Keep this title")
564 await model.discardAll(); account.drainer.stop()
565 }
566
567 func testRenameAfterTitlePOSTButBeforeBatchAckKeepsSuccessorTrigger() async throws {
568 let server = IntegratingServer(); try await server.open(initial())
569 let path = ScriptedPathSource(.unsatisfied), disk = MemoryBlobStore()
570 let network = NativeConnectivity(source: path)
571 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
572 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
573 while network.path?.status != .unsatisfied { try await Task.sleep(for: .milliseconds(5)) }
574 await model.open("doc")
575 model.didRender(model.projectionGeneration)
576 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string("A")]))
577 while model.hasPendingInput { try await Task.sleep(for: .milliseconds(5)) }
578 var metadata = try XCTUnwrap(account.metadata("doc"))
579 metadata.pendingTitle = "First"
580 try account.saveMetadata(metadata, key: "doc")
581 try await account.ensureMetadataEntry("doc")
582 let queuedEntries = await account.outbox.entries(lane: "doc:doc")
583 XCTAssertEqual(queuedEntries.map(\.kind), ["update", "metadata"])
584 await server.delayUpdates(); path.send(.satisfied)
585 let started = ContinuousClock.now.advanced(by: .seconds(2))
586 while await server.updateRequestCount() == 0, .now < started { try await Task.sleep(for: .milliseconds(5)) }
587 let flying = await account.outbox.summary.inFlight
588 XCTAssertEqual(flying, 2)
589 metadata = try XCTUnwrap(account.metadata("doc"))
590 metadata.pendingTitle = "Successor"
591 try account.saveMetadata(metadata, key: "doc")
592 try await account.ensureMetadataEntry("doc")
593 let deadline = ContinuousClock.now.advanced(by: .seconds(3))
594 while await server.savedTitle() != "Successor", .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
595 let title = await server.savedTitle(); XCTAssertEqual(title, "Successor")
596 await model.discardAll(); account.drainer.stop()
597 }
598
599 func testLostChangesNoticePersistsUntilDismissal() async throws {
600 let server = IntegratingServer(); try await server.open(initial())
601 let disk = MemoryBlobStore(["outbox/0000000000000001.entry": Data("corrupt".utf8)])
602 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: NativeConnectivity(source: ScriptedPathSource(.unsatisfied)), transport: { server })
603 let home = DocsModel(transport: { server }, offlineAccount: account)
604 await home.load()
605 try await Task.sleep(for: .milliseconds(50))
606 XCTAssertEqual(home.lostChangesNotice, NativeSyncStrings.kit.unsentLost)
607 await home.load()
608 XCTAssertNotNil(home.lostChangesNotice, "A list refresh must not dismiss the notice")
609 await home.dismissLostChangesNotice()
610 try await Task.sleep(for: .milliseconds(50))
611 XCTAssertNil(home.lostChangesNotice)
612 account.drainer.stop()
613 }
614
615 func testLaneCapCannotBeBypassedAndClearsAfterDrain() async throws {
616 let server = IntegratingServer(); try await server.open(initial())
617 let network = NativeConnectivity(source: ScriptedPathSource(.unsatisfied))
618 let account = DocsOfflineAccount(store: MemoryBlobStore(), accountID: "a", connectivity: network, transport: { server })
619 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
620 await model.open("doc")
621 try await account.outbox.enqueue(lane: "doc:doc", kind: "metadata", payload: Data(repeating: 0, count: 32 * 1024 * 1024 + 1))
622 try await Task.sleep(for: .milliseconds(50))
623 XCTAssertTrue(model.inputPaused)
624 XCTAssertEqual(model.syncStatus, .failed(pending: 1, reason: .tooManyUnsent))
625 await model.retry(); XCTAssertTrue(model.inputPaused)
626 let reopened = DocsModel(transport: { server }, connectivity: network, offlineAccount: account)
627 await reopened.open("doc")
628 XCTAssertTrue(reopened.inputPaused, "Reopening cannot bypass the byte ceiling")
629 try await account.outbox.discard(lane: "doc:doc")
630 try await Task.sleep(for: .milliseconds(50))
631 XCTAssertFalse(model.inputPaused)
632 XCTAssertEqual(model.syncStatus, .saved)
633 await reopened.discardAll(); await model.discardAll(); account.drainer.stop()
634 }
635
636 func testIntegratingServerStreamsSubsequentUpdates() async throws {
637 let server = IntegratingServer(), seed = try await initial()
638 try await server.open(seed)
639 let events = expectation(description: "Initial and subsequent sequence")
640 events.expectedFulfillmentCount = 2
641 let listener = Task { try await server.stream("/d/doc/stream", onOpen: {}) { _ in events.fulfill() } }
642 defer { listener.cancel() }
643 try await Task.sleep(for: .milliseconds(30))
644 let local = CRDTReplica(guid: "offline-property"); _ = try await local.open(update: seed)
645 let update = try await edit(local, "Remote")
646 _ = try await server.responseDetails("/d/doc/updates", method: "POST", body: update, contentType: "application/octet-stream", maxBytes: 1024)
647 await fulfillment(of: [events], timeout: 1)
648 await local.close()
649 }
650
651 func testDraftAttachmentReopensAfterEveryStoreOperation() async throws {
652 let seed = try await initial(), base = MemoryBlobStore()
653 let network = NativeConnectivity(source: ScriptedPathSource(.satisfied))
654 let original = DocsOfflineAccount(store: base, accountID: "a", connectivity: network, transport: { nil })
655 let detail = DocumentDetail(sqid: "draft-token", title: "Draft title", guid: "offline-property", can_write: true, can_share: false)
656 try original.saveMetadata(DocsOfflineMetadata(detail: detail, pendingTitle: "Draft title", draftToken: "token"), key: "draft-token")
657 let source = CRDTReplica(guid: detail.guid); _ = try await source.open(update: seed)
658 try await original.outbox.enqueue(lane: "doc:draft-token", kind: "update", payload: seed)
659 try await original.outbox.enqueue(lane: "doc:draft-token", kind: "update", payload: edit(source, "Draft content"))
660 await source.close()
661 // Discover the actual operation count, then crash before every one.
662 var limit = 0
663 for crashAfter in [-1] + Array(0...80) {
664 if crashAfter > limit && crashAfter >= 0 { break }
665 let server = IntegratingServer(); try await server.open(Data())
666 let disk = FaultingBlobStore(MemoryBlobStore(base.contents))
667 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
668 if crashAfter >= 0 { disk.crash(after: crashAfter) }
669 do { try await account.activate(); await account.drainer.drain() } catch is SimulatedCrash { }
670 account.drainer.stop()
671 if crashAfter == -1 { limit = disk.operationCount }
672 let frozen = disk.committed
673 let recovered = DocsOfflineAccount(store: frozen, accountID: "a", connectivity: network, transport: { server })
674 try await recovered.activate(); await recovered.drainer.drain()
675 XCTAssertEqual(try recovered.key(for: "doc"), "draft-token", "Boundary \(crashAfter)")
676 XCTAssertNil(try recovered.metadata("draft-token")?.draftToken)
677 let text = try await server.text(), count = await server.documentCount(), summary = await recovered.outbox.summary
678 XCTAssertEqual(text, "Draft content", "Boundary \(crashAfter)")
679 XCTAssertEqual(count, 1, "Creation-token retry cannot duplicate the document")
680 XCTAssertTrue(summary.isEmpty)
681 let reopened = CRDTReplica(guid: detail.guid)
682 let lane = DocsPersistence(store: frozen, outbox: recovered.outbox, key: "draft-token", replica: reopened)
683 try await lane.reopen(fallback: Data(), sequence: 0)
684 let offline = try await reopened.projection().children.map(\.plainText).joined()
685 XCTAssertEqual(offline, "Draft content")
686 await reopened.close(); recovered.drainer.stop()
687 }
688 XCTAssertLessThanOrEqual(limit, 80, "Expand the exhaustive sweep if attachment grows")
689 }
690
691 func testDirectDocumentOpenReportsLostKeyBeforeReadingItsIndex() async throws {
692 let server = IntegratingServer(); try await server.open(initial())
693 let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
694 defer { try? FileManager.default.removeItem(at: root) }
695 let keys = ScriptedKeyProvider(key: SymmetricKey(size: .bits256))
696 let disk = NativeSealedStore(root: root, accountID: "a", keys: keys)
697 let network = NativeConnectivity(source: ScriptedPathSource(.unsatisfied))
698 let old = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
699 let detail = DocumentDetail(sqid: "doc", title: "Document", guid: "offline-property", can_write: true, can_share: false)
700 try old.saveMetadata(DocsOfflineMetadata(detail: detail), key: "doc")
701 try await old.outbox.enqueue(lane: "doc:doc", kind: "update", payload: Data([0]))
702 _ = keys.deleteKey()
703 // A process restart also drops NativeSealedStore's cached key/state.
704 // Reusing the old store would deliberately keep the still-usable key.
705 let reopened = NativeSealedStore(root: root, accountID: "a", keys: keys)
706 let next = DocsOfflineAccount(store: reopened, accountID: "a", connectivity: network, transport: { server })
707 let model = DocsModel(transport: { server }, connectivity: network, offlineAccount: next)
708 await model.open("doc")
709 try await Task.sleep(for: .milliseconds(50))
710 XCTAssertNotNil(model.projection)
711 XCTAssertEqual(model.lostChangesNotice, NativeSyncStrings.kit.unsentLost)
712 await model.discardAll(); next.drainer.stop()
713 }
714
715 func testDraftSeedSurvivesMetadataWriteFailureAndConvergesAfterUnlock() async throws {
716 let server = IntegratingServer(); try await server.open(Data())
717 let disk = MetadataLockStore(), network = NativeConnectivity(source: ScriptedPathSource(.satisfied))
718 let account = DocsOfflineAccount(store: disk, accountID: "a", connectivity: network, transport: { server })
719 let model = DocsModel(transport: { server }, connectivity: network, draft: true, offlineAccount: account)
720 await model.newDraft()
721 XCTAssertTrue(model.inputPaused)
722 disk.unlock(); await model.retry()
723 XCTAssertNotNil(model.projection)
724 model.didRender(model.projectionGeneration)
725 XCTAssertTrue(model.enqueue(["op": .string("display_edit"), "at": .number(0), "length": .number(0), "text": .string("Recovered draft")]))
726 while model.hasPendingInput { try await Task.sleep(for: .milliseconds(5)) }
727 let saved = await model.saveAll(); XCTAssertTrue(saved)
728 let text = try await server.text()
729 XCTAssertEqual(text, "Recovered draft", "The paragraph dependency must reach the server too")
730 await model.discardAll(); account.drainer.stop()
731 }
732
733}
734