Found while fixing #82. Split out rather than folded in, because the fix is a SwiftData schema change and #82 is test hygiene.
The defect
The document sync outbox is a FIFO queue whose order is established by a non-total sort key.
SwiftDataDocumentStore.enqueueOutbox stamps enqueuedAt: Date() — Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataDocumentStore.swift:166
SwiftDataDocumentStore.outboxEntries sorts by that field alone — …/SwiftDataDocumentStore.swift:176
FetchDescriptor<OutboxEntryRecord>(
sortBy: [SortDescriptor(\.enqueuedAt, order: .forward)]
)
Two entries stamped in the same instant tie, and SortDescriptor specifies no tiebreak, so their relative order is whatever the store returns. For a queue whose entire contract is replay these changes in the order they happened, that is a correctness hole rather than a cosmetic one: .updateDocument replayed before the .createDocument it depends on, or a .deleteFolder overtaking the .renameFolder ahead of it.
The evidence that this is known, not theoretical
The tests already work around it, and say so:
try await store.enqueueOutbox(.deleteDocument(id: "first"))
// SwiftData uses Date() at enqueue — sleep briefly so timestamps differ.
try await Task.sleep(nanoseconds: 5_000_000)
— Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataDocumentStoreTests.swift:181
and again at :183 and :246 (the latter sleeping 2 ms between each of six enqueues). Four sleeps whose only job is to keep the production sort key total. A test that has to slow the system down to make the assertion true is describing a defect in the system, not a defect in the test.
Why it has not obviously bitten
Each enqueueOutbox performs a context.save(), which costs far more than Date()'s resolution — so in practice consecutive enqueues usually do get distinct stamps. "Usually" is the problem: the failure mode is silent, data-shaped, and would present as a sync bug with no stack trace.
Fix
Add a monotonic sequence to OutboxEntryRecord and sort by it, keeping enqueuedAt as the human-facing timestamp it already is:
sortBy: [SortDescriptor(\.sequence, order: .forward)]
- The column is additive with a default, so SwiftData lightweight migration covers existing stores — but confirm that against a populated store, not only an in-memory one.
- The counter must be derived from the store (
max(sequence) + 1 inside the same save), not from a process-local static, or two app launches restart the numbering.
- Once it lands, delete the four sleeps in
SwiftDataDocumentStoreTests — they are the regression test: if enqueuing six changes back-to-back with no delay still reads back in order, the fix works.
Acceptance
outboxEntries() returns insertion order for entries enqueued with no delay between them.
- The six-change round-trip test at
SwiftDataDocumentStoreTests:246 passes with zero sleeps.
- An existing on-disk store opens without loss after the migration.
- BDD quartet: happy (ordered replay), invalid (unreadable payload still dropped, order of the rest preserved), upstream-failure (a failed dequeue leaves order intact), boundary (empty outbox; entries enqueued in the same millisecond).
Found while fixing #82. Split out rather than folded in, because the fix is a SwiftData schema change and #82 is test hygiene.
The defect
The document sync outbox is a FIFO queue whose order is established by a non-total sort key.
SwiftDataDocumentStore.enqueueOutboxstampsenqueuedAt: Date()—Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataDocumentStore.swift:166SwiftDataDocumentStore.outboxEntriessorts by that field alone —…/SwiftDataDocumentStore.swift:176Two entries stamped in the same instant tie, and
SortDescriptorspecifies no tiebreak, so their relative order is whatever the store returns. For a queue whose entire contract is replay these changes in the order they happened, that is a correctness hole rather than a cosmetic one:.updateDocumentreplayed before the.createDocumentit depends on, or a.deleteFolderovertaking the.renameFolderahead of it.The evidence that this is known, not theoretical
The tests already work around it, and say so:
—
Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataDocumentStoreTests.swift:181and again at
:183and:246(the latter sleeping 2 ms between each of six enqueues). Four sleeps whose only job is to keep the production sort key total. A test that has to slow the system down to make the assertion true is describing a defect in the system, not a defect in the test.Why it has not obviously bitten
Each
enqueueOutboxperforms acontext.save(), which costs far more thanDate()'s resolution — so in practice consecutive enqueues usually do get distinct stamps. "Usually" is the problem: the failure mode is silent, data-shaped, and would present as a sync bug with no stack trace.Fix
Add a monotonic sequence to
OutboxEntryRecordand sort by it, keepingenqueuedAtas the human-facing timestamp it already is:max(sequence) + 1inside the same save), not from a process-local static, or two app launches restart the numbering.SwiftDataDocumentStoreTests— they are the regression test: if enqueuing six changes back-to-back with no delay still reads back in order, the fix works.Acceptance
outboxEntries()returns insertion order for entries enqueued with no delay between them.SwiftDataDocumentStoreTests:246passes with zero sleeps.