Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion App/Features/AI/AIDocumentSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct AIDocumentSheet: View {
/// populated without the host having to hold lists it does not otherwise need.
@State private var lists: [OwnedList] = []
/// Documents the user owns, offered when deriving from an article.
var documents: [Document] = []
var documents: [InterlinedDomain.Document] = []
/// Called after a drafted document is created, so the host can reload it.
var onCreated: (() async -> Void)?

Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/ConflictBannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import InterlinedDomain
struct ConflictBannerView: View {

let pending: ConflictBannerViewModel.Pending
let onOpenLocalCopy: (Document.ID) -> Void
let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void
let onDismiss: () -> Void

var body: some View {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/DocumentEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Textual
struct DocumentEditorView: View {

let viewModel: DocumentEditorViewModel
let onOpenLocalCopy: (Document.ID) -> Void
let onOpenLocalCopy: (InterlinedDomain.Document.ID) -> Void

var body: some View {
VStack(spacing: 0) {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/DocumentTemplatePickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ struct DocumentTemplatePickerView: View {

/// Called with the created document on success so the caller (the root
/// view) can bind the editor to it. Not called on failure.
let onCreated: (Document) -> Void
let onCreated: (InterlinedDomain.Document) -> Void

/// The built-in catalog to present. Defaults to the bundled built-ins;
/// injectable so previews can substitute a list.
Expand Down
23 changes: 20 additions & 3 deletions App/Features/Documents/DocumentsListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,27 @@
import SwiftUI
import InterlinedDomain

// `Document` is written as `InterlinedDomain.Document` throughout the SwiftUI
// files in this feature, and that qualification is load-bearing.
//
// The macOS 27 SDK added a `Document` **protocol** to SwiftUI
// (`protocol Document: ReadableDocument, WritableDocument`), which collides with
// the domain's `Document` **struct** in any file importing both — which is every
// documents view. Before Xcode 27 the bare name resolved; after it, the same
// source stopped compiling with `'Document' is ambiguous for type lookup`
// (GitHub #98).
//
// The domain type is not renamed: `Document` is the right name for it, it is
// correct across Kit, Domain, Persistence and their tests, and renaming a core
// model to dodge a collision in one consumer is the tail wagging the dog. A
// `typealias` would shorten the use sites at the cost of giving one concept two
// names. Only the SwiftUI-importing files need this; the view models import
// Foundation and Observation, not SwiftUI, and are unaffected.

struct DocumentsListView: View {

let viewModel: DocumentsListViewModel
let onSelect: (Document.ID?) -> Void
let onSelect: (InterlinedDomain.Document.ID?) -> Void

/// Source of the **Move to folder** destinations. Optional so the column
/// still renders in isolation (previews, and any future host that has no
Expand All @@ -21,7 +38,7 @@ struct DocumentsListView: View {

/// Called with the document that was moved, so the host can rebind an open
/// editor to the server's relocated copy.
var onMoved: ((Document) -> Void)? = nil
var onMoved: ((InterlinedDomain.Document) -> Void)? = nil

var body: some View {
List(selection: Binding(
Expand Down Expand Up @@ -103,7 +120,7 @@ struct DocumentsListView: View {
// MARK: - DocumentRowView

private struct DocumentRowView: View {
let document: Document
let document: InterlinedDomain.Document

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Expand Down
4 changes: 2 additions & 2 deletions App/Features/Documents/DocumentsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ struct DocumentsRootView: View {
/// was started from the editor, so there is exactly one optimistic-rollback
/// implementation rather than two that can disagree.
private func handleMove(
documentID: Document.ID,
documentID: InterlinedDomain.Document.ID,
to destination: FolderNode.ID?,
folderTree: FolderTreeViewModel,
documentsList: DocumentsListViewModel,
Expand All @@ -449,7 +449,7 @@ struct DocumentsRootView: View {
}

private func handleOpenLocalCopy(
_ id: Document.ID,
_ id: InterlinedDomain.Document.ID,
documentsList: DocumentsListViewModel,
editor: DocumentEditorViewModel
) {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Documents/PublicUserDocumentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ struct PublicUserDocumentsView: View {

private struct PublicDocumentRow: View {

let document: Document
let document: InterlinedDomain.Document

var body: some View {
VStack(alignment: .leading, spacing: 2) {
Expand Down
2 changes: 1 addition & 1 deletion App/Features/Search/SearchRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ struct SearchRootView: View {
/// a relative "updated" stamp. Kept local to the Search feature because
/// the Documents feature's own row component is file-private.
private struct DocumentSearchRow: View {
let document: Document
let document: InterlinedDomain.Document

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,41 @@ final class OutboxEntryRecord {
/// JSON-encoded `DocumentChange`. Decoded by `DocumentChangeCodec`.
var payloadJSON: Data

/// When the change was queued. A human-facing timestamp — it is shown in
/// sync UI and used for staleness decisions.
///
/// - Important: **not** the sort key. See ``sequence``.
var enqueuedAt: Date

/// The queue position. Strictly increasing, assigned inside the same save
/// that inserts the row.
///
/// This exists because `enqueuedAt` is **not a total order** (GitHub #84).
/// The outbox is a FIFO whose entire contract is *replay these changes in
/// the order they happened*, and it was sorted by timestamp alone — two
/// entries stamped in the same instant tie, and `SortDescriptor` specifies
/// no tiebreak, so their relative order was whatever the store happened to
/// return. For a document-sync queue that means an `.updateDocument`
/// replayed before the `.createDocument` it depends on, or a
/// `.deleteFolder` overtaking the `.renameFolder` ahead of it.
///
/// The tests knew: three of them slept between enqueues, commented
/// *"SwiftData uses Date() at enqueue — sleep briefly so timestamps
/// differ."* A test that has to slow the system down to make its assertion
/// true is describing a defect in the system.
///
/// Derived from `max(sequence) + 1` **read from the store**, not from a
/// process-local counter: a counter would restart at zero on the next
/// launch and interleave new entries among old ones.
///
/// Additive with a default, so SwiftData's lightweight migration opens an
/// existing store. Rows written before this field arrive as `0` and
/// therefore sort ahead of everything new — which is correct, because they
/// *are* older. Their order relative to each other is whatever it already
/// was; this change cannot retroactively recover an order that was never
/// recorded.
var sequence: Int = 0

var attemptCount: Int
var lastError: String?

Expand All @@ -36,6 +70,7 @@ final class OutboxEntryRecord {
targetId: String,
payloadJSON: Data,
enqueuedAt: Date,
sequence: Int = 0,
attemptCount: Int = 0,
lastError: String? = nil
) {
Expand All @@ -44,6 +79,7 @@ final class OutboxEntryRecord {
self.targetId = targetId
self.payloadJSON = payloadJSON
self.enqueuedAt = enqueuedAt
self.sequence = sequence
self.attemptCount = attemptCount
self.lastError = lastError
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,21 +159,55 @@ public actor SwiftDataDocumentStore: DocumentStore {
public func enqueueOutbox(_ change: DocumentChange) async throws {
let context = self.context
let payload = try DocumentChangeCodec.encode(change)
// The queue position is read from the store and assigned in the same
// save as the insert (GitHub #84). A process-local counter would
// restart at zero on the next launch and interleave new entries among
// old ones; the store is the only thing that knows where the queue got
// to.
//
// `enqueueOutbox` is the single writer — the store is actor-isolated and
// every caller goes through it — so the read-then-write is not a race
// with another enqueue.
let row = OutboxEntryRecord(
kind: change.kind.rawValue,
targetId: change.targetId,
payloadJSON: payload,
enqueuedAt: Date()
enqueuedAt: Date(),
sequence: nextOutboxSequence(context: context)
)
context.insert(row)
try context.save()
}

/// The next free queue position: one past the highest currently stored.
///
/// Computed from `max` rather than from the row count, because dequeuing
/// removes rows — a count-based sequence would reissue a position already
/// used by a row still waiting behind it, and two entries sharing a position
/// puts the ordering right back where it started.
private func nextOutboxSequence(context: ModelContext) -> Int {
var descriptor = FetchDescriptor<OutboxEntryRecord>(
sortBy: [SortDescriptor(\.sequence, order: .reverse)]
)
descriptor.fetchLimit = 1
// A failed read must not reuse position 0 and silently re-tie the queue.
// Falling back to the row count keeps new entries after existing ones in
// the overwhelmingly common case, and the empty-store case is 0 anyway.
guard let highest = try? context.fetch(descriptor).first?.sequence else {
logger.error("nextOutboxSequence: fetch failed; falling back to the row count")
return (try? context.fetchCount(FetchDescriptor<OutboxEntryRecord>())) ?? 0
}
return highest + 1
}

public func outboxEntries() async -> [OutboxEntry] {
let context = self.context
do {
// Sorted by `sequence`, which is a total order. `enqueuedAt` is not
// — two entries stamped in the same instant tie, and the tiebreak is
// whatever the store returns (GitHub #84).
let descriptor = FetchDescriptor<OutboxEntryRecord>(
sortBy: [SortDescriptor(\.enqueuedAt, order: .forward)]
sortBy: [SortDescriptor(\.sequence, order: .forward)]
)
return try context.fetch(descriptor).compactMap { record in
guard let change = try? DocumentChangeCodec.decode(record.payloadJSON) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,11 @@ final class DocumentSyncEngineTests: XCTestCase {
func test_givenOutboxWithMultipleChanges_whenSyncing_thenAllPushedInOrder() async throws {
// Given
let store = try SwiftDataDocumentStore.inMemory()
// No sleeps between enqueues: the outbox is ordered by a monotonic
// sequence now, not by `Date()` resolution (GitHub #84). Pushing in
// order is exactly what this test asserts, so enqueuing back to back is
// the stronger version of it.
try await store.enqueueOutbox(.updateDocument(id: "a", title: "1", body: nil, folderId: nil, isPublic: nil))
try await Task.sleep(nanoseconds: 5_000_000)
try await store.enqueueOutbox(.updateDocument(id: "b", title: "2", body: nil, folderId: nil, isPublic: nil))
let transport = StubSyncTransport()
await transport.enqueuePull(DocumentSyncDelta())
Expand All @@ -267,9 +270,7 @@ final class DocumentSyncEngineTests: XCTestCase {
// Given — three changes; the middle one fails.
let store = try SwiftDataDocumentStore.inMemory()
try await store.enqueueOutbox(.deleteDocument(id: "first"))
try await Task.sleep(nanoseconds: 5_000_000)
try await store.enqueueOutbox(.deleteDocument(id: "second"))
try await Task.sleep(nanoseconds: 5_000_000)
try await store.enqueueOutbox(.deleteDocument(id: "third"))
let transport = StubSyncTransport()
await transport.enqueuePull(DocumentSyncDelta())
Expand Down Expand Up @@ -397,7 +398,16 @@ final class DocumentSyncEngineTests: XCTestCase {

// When
_ = try await engine.syncNow()
try await Task.sleep(nanoseconds: 50_000_000)
// Poll for the post-condition rather than sleeping a fixed 50 ms: the
// collector's own count is the thing being waited for, and a fixed wait
// passes on an idle machine and fails under load (the same lesson as
// GitHub #82).
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while ContinuousClock.now < deadline {
if await collector.all.count >= 3 { break }
await Task.yield()
try? await Task.sleep(for: .milliseconds(1))
}
task.cancel()

// Then — exact order: conflictResolved, deltaApplied, pushed.
Expand Down
Loading