@@ -15,6 +15,7 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
1515 public func synchronize(
1616 localEvents: [ TokenEvent ] ,
1717 replaceLocalLedger: Bool = false ,
18+ importedAfter: Date ? = nil ,
1819 isCancelled: ( ) -> Bool = { false }
1920 ) -> ( events: [ TokenEvent ] , status: SyncFolderStatus ) {
2021 guard fileManager. fileExists ( atPath: folder. path) else {
@@ -37,7 +38,7 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
3738 exportError = error. localizedDescription
3839 }
3940
40- let readResult = readDeviceLedgers ( isCancelled: isCancelled)
41+ let readResult = readDeviceLedgers ( importedAfter : importedAfter , isCancelled: isCancelled)
4142 let status = SyncFolderStatus (
4243 path: folder. path,
4344 exists: true ,
@@ -63,35 +64,83 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
6364 let localLedgerURL = devicesURL. appendingPathComponent ( " \( safeFileName ( localDevice. id) ) .jsonl " )
6465
6566 var recordsByKey : [ String : SyncLedgerRecord ] = [ : ]
66- if !replaceExisting, fileManager. fileExists ( atPath: localLedgerURL. path) {
67- let existing = readRecords ( from: localLedgerURL, isCancelled: isCancelled) . records
68- for record in existing {
69- recordsByKey [ record. identityKey] = record
70- }
71- }
72-
7367 for event in events where !isCancelled( ) {
7468 let record = SyncLedgerRecord ( event: event. withDevice ( localDevice) )
7569 recordsByKey [ record. identityKey] = record
7670 }
7771
78- let records = recordsByKey. values. sorted {
72+ let records : [ SyncLedgerRecord ]
73+ if replaceExisting {
74+ records = sortedRecords ( Array ( recordsByKey. values) )
75+ try write ( records: records, to: localLedgerURL)
76+ return records. count
77+ }
78+
79+ let existingKeys = readIdentityKeys ( from: localLedgerURL, isCancelled: isCancelled)
80+ records = sortedRecords ( recordsByKey. values. filter { !existingKeys. contains ( $0. identityKey) } )
81+ guard !records. isEmpty else { return 0 }
82+
83+ try append ( records: records, to: localLedgerURL)
84+ return records. count
85+ }
86+
87+ private func sortedRecords( _ records: [ SyncLedgerRecord ] ) -> [ SyncLedgerRecord ] {
88+ records. sorted {
7989 if $0. timestamp == $1. timestamp {
8090 return $0. eventId < $1. eventId
8191 }
8292 return $0. timestamp < $1. timestamp
8393 }
94+ }
95+
96+ private func write( records: [ SyncLedgerRecord ] , to url: URL ) throws {
8497 let encoder = Self . jsonEncoder
8598 let lines = try records. map { record in
8699 let data = try encoder. encode ( record)
87100 return String ( decoding: data, as: UTF8 . self)
88101 }
89102 let data = ( lines. joined ( separator: " \n " ) + ( lines. isEmpty ? " " : " \n " ) ) . data ( using: . utf8) ?? Data ( )
90- try data. write ( to: localLedgerURL, options: [ . atomic] )
91- return events. count
103+ try data. write ( to: url, options: [ . atomic] )
104+ }
105+
106+ private func append( records: [ SyncLedgerRecord ] , to url: URL ) throws {
107+ let encoder = Self . jsonEncoder
108+ let lines = try records. map { record in
109+ let data = try encoder. encode ( record)
110+ return String ( decoding: data, as: UTF8 . self)
111+ }
112+ guard !lines. isEmpty else { return }
113+
114+ if !fileManager. fileExists ( atPath: url. path) {
115+ let data = ( lines. joined ( separator: " \n " ) + " \n " ) . data ( using: . utf8) ?? Data ( )
116+ try data. write ( to: url, options: [ . atomic] )
117+ return
118+ }
119+
120+ let handle = try FileHandle ( forUpdating: url)
121+ defer {
122+ try ? handle. close ( )
123+ }
124+
125+ let endOffset = try handle. seekToEnd ( )
126+ if endOffset > 0 {
127+ try handle. seek ( toOffset: endOffset - 1 )
128+ let lastByte = handle. readDataToEndOfFile ( )
129+ if lastByte != Data ( [ 0x0A ] ) {
130+ try handle. seekToEnd ( )
131+ try handle. write ( contentsOf: Data ( [ 0x0A ] ) )
132+ }
133+ }
134+
135+ try handle. seekToEnd ( )
136+ let data = ( lines. joined ( separator: " \n " ) + " \n " ) . data ( using: . utf8) ?? Data ( )
137+ try handle. write ( contentsOf: data)
92138 }
93139
94- private func readDeviceLedgers( isCancelled: ( ) -> Bool ) -> ( events: [ TokenEvent ] , deviceFileCount: Int , parseErrorCount: Int ) {
140+ private func readDeviceLedgers(
141+ importedAfter: Date ? ,
142+ isCancelled: ( ) -> Bool
143+ ) -> ( events: [ TokenEvent ] , deviceFileCount: Int , parseErrorCount: Int ) {
95144 let devicesURL = folder. appendingPathComponent ( " devices " , isDirectory: true )
96145 guard fileManager. fileExists ( atPath: devicesURL. path) else {
97146 return ( [ ] , 0 , 0 )
@@ -111,15 +160,35 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
111160
112161 for case let url as URL in enumerator where !isCancelled( ) && url. pathExtension == " jsonl " {
113162 fileCount += 1
114- let result = readRecords ( from: url, isCancelled: isCancelled)
163+ let result = readRecords ( from: url, importedAfter : importedAfter , isCancelled: isCancelled)
115164 parseErrors += result. parseErrorCount
116165 events. append ( contentsOf: result. records. map ( \. tokenEvent) )
117166 }
118167
119168 return ( events, fileCount, parseErrors)
120169 }
121170
122- private func readRecords( from url: URL , isCancelled: ( ) -> Bool ) -> ( records: [ SyncLedgerRecord ] , parseErrorCount: Int ) {
171+ private func readIdentityKeys( from url: URL , isCancelled: ( ) -> Bool ) -> Set < String > {
172+ guard fileManager. fileExists ( atPath: url. path) else { return [ ] }
173+ guard let content = try ? String ( contentsOf: url, encoding: . utf8) else { return [ ] }
174+
175+ let decoder = Self . jsonDecoder
176+ var keys : Set < String > = [ ]
177+ for line in content. split ( separator: " \n " , omittingEmptySubsequences: true ) where !isCancelled( ) {
178+ guard let data = String ( line) . data ( using: . utf8) ,
179+ let identity = try ? decoder. decode ( SyncLedgerIdentity . self, from: data) else {
180+ continue
181+ }
182+ keys. insert ( identity. identityKey)
183+ }
184+ return keys
185+ }
186+
187+ private func readRecords(
188+ from url: URL ,
189+ importedAfter: Date ? ,
190+ isCancelled: ( ) -> Bool
191+ ) -> ( records: [ SyncLedgerRecord ] , parseErrorCount: Int ) {
123192 guard let content = try ? String ( contentsOf: url, encoding: . utf8) else {
124193 return ( [ ] , 1 )
125194 }
@@ -135,6 +204,10 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
135204 }
136205
137206 do {
207+ if let importedAfter {
208+ let header = try decoder. decode ( SyncLedgerRecordHeader . self, from: data)
209+ guard header. timestamp >= importedAfter else { continue }
210+ }
138211 let record = try decoder. decode ( SyncLedgerRecord . self, from: data)
139212 records. append ( record)
140213 } catch {
@@ -194,6 +267,24 @@ public final class TokenSyncLedgerStore: @unchecked Sendable {
194267 }
195268}
196269
270+ private struct SyncLedgerIdentity : Decodable {
271+ var deviceId : String
272+ var eventId : String
273+
274+ var identityKey : String {
275+ " \( deviceId) | \( eventId) "
276+ }
277+
278+ enum CodingKeys : String , CodingKey {
279+ case deviceId = " device_id "
280+ case eventId = " event_id "
281+ }
282+ }
283+
284+ private struct SyncLedgerRecordHeader : Decodable {
285+ var timestamp : Date
286+ }
287+
197288private struct SyncLedgerRecord : Codable , Hashable {
198289 var schemaVersion : Int
199290 var deviceId : String
0 commit comments