Skip to content

Conversation

@mbrandonw
Copy link
Member

Right now it's possible for dates to change by round tripping them to the database and back into Swift. This is due to the fact that Date is the de facto standard for dates in Swift, and Date represents time with a double.

This is a test from the PR that failed prior to the changes to show the problem:

  @Test func roundtrip() throws {
    let date = Date(timeIntervalSinceReferenceDate: 793109282.061)
    let insertedRecord = try database.write { db in
      try Record.insert { Record.Draft(date: date) }
      .returning(\.self)
      .fetchOne(db)!
    }
    let updatedRecord = try database.write { db in
      try Record
        .update(insertedRecord)
      .returning(\.self)
      .fetchOne(db)!
    }
    #expect(insertedRecord.date == date)
    #expect(insertedRecord.date == updatedRecord.date)
  }

To fix we need to nextUp the date before turning it into an ISO8601 formatted string.

}
}
if didSet || isRowValueModified {
if didSet || (syncEngineHasPendingChanges && isRowValueModified) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a somewhat unrelated fixed, but it was exacerbated by the date problem. We should only skip updating a local field of a record if there are pending iCloud changes for that record. That's because in that situation we interpret that to mean the user made local changes that will eventually be sent out to iCloud.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity, how is it possible for a row to have a modification without having pending changes enqueued in the sync engine? Aren’t row changes automatically picked up by a trigger and enqueued?

Or is this a guardrail against false positives from the equality check? If so, would it really work, as there might be unrelated changes to other fields of the same record?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukaskubanek It should not be possible, but 1) the user of the library could do something bad like make changes to the database with a different SQLite connection than what the SyncEngine has, and 2) some types may not roundtrip cleanly to and from SQLite (such as dates). Whenever either of those happen we don't want that field to be forever stuck and never update. Adding this condition allows us to kick things in the butt if we detect a change to a field that has no corresponding pending change.

If so, would it really work, as there might be unrelated changes to other fields of the same record?

Yeah, I believe this is not 100% foolproof, but it works one particular use case now, and we have good test coverage on it, so in the future we could explore more robust solutions.

extension Date {
@usableFromInline
var iso8601String: String {
let nextUpDate = Date(timeIntervalSinceReferenceDate: timeIntervalSinceReferenceDate.nextUp)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix for the date issue.

@lukaskubanek
Copy link
Contributor

@mbrandonw Just saw this PR in my inbox and it reminded me of #275 I reported a while back. Does it mean that with this change upserts will no longer yield false positives when checking for modifications on Date fields? Or, in other words, is isRowValueModified now going to report the correct result for dates?

@mbrandonw
Copy link
Member Author

@mbrandonw Just saw this PR in my inbox and it reminded me of #275 I reported a while back. Does it mean that with this change upserts will no longer yield false positives when checking for modifications on Date fields? Or, in other words, is isRowValueModified now going to report the correct result for dates?

Ah I forgot about that issue, but yes this should fix it. The problem is a combination of dates being represented by doubles in Swift and ISO8601 formatted strings in SQLite. For example, using the date I used in the description of this PR, we can turn it into an ISO8601 string and back into a date a few times to see it changes multiple times:

> let date = Date(timeIntervalSinceReferenceDate: 793109282.061) 
date: Foundation.Date = 2026-02-18 12:08:02 UTC

> let string = date.formatted(.iso8601.currentTimestamp(includingFractionalSeconds: true)) 
string: Foundation.Date.ISO8601FormatStyle.FormatOutput = "2026-02-18 12:08:02.060"

> let newDate = Date(iso8601String: string) 
newDate: Foundation.Date = 2026-02-18 12:08:02 UTC

> let newString = newDate.formatted(.iso8601.currentTimestamp(includingFractionalSeconds: true)) 
newString: Foundation.Date.ISO8601FormatStyle.FormatOutput = "2026-02-18 12:08:02.059"

Notice that string and newString have drifted. That means that device A can save a date to SQLite as an ISO8601 string, fetch the date as a double, send it to iCloud, and then device B downloads that double, and saves it SQLite as an IO8601 string, resulting in device A and device B having different ISO8601 strings.

We have fixed this by nextUp'ing the double before turning it into an ISO8601 string so that we can be sure that the double we are saving is precisely represented and so will roundtrip to ISO8601 and back just fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants