diff --git a/AutomergeUniffi/automerge.swift b/AutomergeUniffi/automerge.swift index b5b180f9..f9fdb479 100644 --- a/AutomergeUniffi/automerge.swift +++ b/AutomergeUniffi/automerge.swift @@ -418,6 +418,10 @@ public protocol DocProtocol: AnyObject { func changes() -> [ChangeHash] + func changeByHash(hash: ChangeHash) -> Change? + + func commitWith(msg: String?, time: Int64) + func cursor(obj: ObjId, position: UInt64) throws -> Cursor func cursorAt(obj: ObjId, position: UInt64, heads: [ChangeHash]) throws -> Cursor @@ -521,6 +525,7 @@ public protocol DocProtocol: AnyObject { func values(obj: ObjId) throws -> [Value] func valuesAt(obj: ObjId, heads: [ChangeHash]) throws -> [Value] + } public class Doc: @@ -614,6 +619,20 @@ public class Doc: ) } + public func changeByHash(hash: ChangeHash) -> Change? { + try! FfiConverterOptionTypeChange.lift( + try! + rustCall { + uniffi_uniffi_automerge_fn_method_doc_change_by_hash( + self.uniffiClonePointer(), + + FfiConverterTypeChangeHash.lower(hash), + $0 + ) + } + ) + } + public func cursor(obj: ObjId, position: UInt64) throws -> Cursor { try FfiConverterTypeCursor.lift( rustCallWithError(FfiConverterTypeDocError.lift) { @@ -1219,6 +1238,18 @@ public class Doc: } ) } + public func commitWith(msg: String?, time: Int64) { + try! + rustCall { + uniffi_uniffi_automerge_fn_method_doc_commit_with( + self.uniffiClonePointer(), + + FfiConverterOptionString.lower(msg), + FfiConverterInt64.lower(time), + $0 + ) + } + } public func save() -> [UInt8] { try! FfiConverterSequenceUInt8.lift( @@ -1495,6 +1526,96 @@ public func FfiConverterTypeSyncState_lower(_ value: SyncState) -> UnsafeMutable FfiConverterTypeSyncState.lower(value) } +public struct Change { + public var actorId: ActorId + public var message: String? + public var deps: [ChangeHash] + public var timestamp: Int64 + public var bytes: [UInt8] + public var hash: ChangeHash + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + actorId: ActorId, + message: String?, + deps: [ChangeHash], + timestamp: Int64, + bytes: [UInt8], + hash: ChangeHash + ) { + self.actorId = actorId + self.message = message + self.deps = deps + self.timestamp = timestamp + self.bytes = bytes + self.hash = hash + } +} + +extension Change: Equatable, Hashable { + public static func == (lhs: Change, rhs: Change) -> Bool { + if lhs.actorId != rhs.actorId { + return false + } + if lhs.message != rhs.message { + return false + } + if lhs.deps != rhs.deps { + return false + } + if lhs.timestamp != rhs.timestamp { + return false + } + if lhs.bytes != rhs.bytes { + return false + } + if lhs.hash != rhs.hash { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(actorId) + hasher.combine(message) + hasher.combine(deps) + hasher.combine(timestamp) + hasher.combine(bytes) + hasher.combine(hash) + } +} + +public struct FfiConverterTypeChange: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Change { + try Change( + actorId: FfiConverterTypeActorId.read(from: &buf), + message: FfiConverterOptionString.read(from: &buf), + deps: FfiConverterSequenceTypeChangeHash.read(from: &buf), + timestamp: FfiConverterInt64.read(from: &buf), + bytes: FfiConverterSequenceUInt8.read(from: &buf), + hash: FfiConverterTypeChangeHash.read(from: &buf) + ) + } + + public static func write(_ value: Change, into buf: inout [UInt8]) { + FfiConverterTypeActorId.write(value.actorId, into: &buf) + FfiConverterOptionString.write(value.message, into: &buf) + FfiConverterSequenceTypeChangeHash.write(value.deps, into: &buf) + FfiConverterInt64.write(value.timestamp, into: &buf) + FfiConverterSequenceUInt8.write(value.bytes, into: &buf) + FfiConverterTypeChangeHash.write(value.hash, into: &buf) + } +} + +public func FfiConverterTypeChange_lift(_ buf: RustBuffer) throws -> Change { + try FfiConverterTypeChange.lift(buf) +} + +public func FfiConverterTypeChange_lower(_ value: Change) -> RustBuffer { + FfiConverterTypeChange.lower(value) +} + public struct KeyValue { public var key: String public var value: Value @@ -2391,6 +2512,48 @@ public func FfiConverterTypeValue_lower(_ value: Value) -> RustBuffer { extension Value: Equatable, Hashable {} +private struct FfiConverterOptionString: FfiConverterRustBuffer { + typealias SwiftType = String? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterString.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +private struct FfiConverterOptionTypeChange: FfiConverterRustBuffer { + typealias SwiftType = Change? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeChange.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeChange.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + private struct FfiConverterOptionTypeValue: FfiConverterRustBuffer { typealias SwiftType = Value? @@ -2835,9 +2998,15 @@ private var initializationResult: InitializationResult { if uniffi_uniffi_automerge_checksum_method_doc_apply_encoded_changes_with_patches() != 63928 { return InitializationResult.apiChecksumMismatch } + if uniffi_uniffi_automerge_checksum_method_doc_change_by_hash() != 44577 { + return InitializationResult.apiChecksumMismatch + } if uniffi_uniffi_automerge_checksum_method_doc_changes() != 1878 { return InitializationResult.apiChecksumMismatch } + if uniffi_uniffi_automerge_checksum_method_doc_commit_with() != 65319 { + return InitializationResult.apiChecksumMismatch + } if uniffi_uniffi_automerge_checksum_method_doc_cursor() != 18441 { return InitializationResult.apiChecksumMismatch } diff --git a/Sources/Automerge/Change.swift b/Sources/Automerge/Change.swift new file mode 100644 index 00000000..9d93d291 --- /dev/null +++ b/Sources/Automerge/Change.swift @@ -0,0 +1,22 @@ +import struct AutomergeUniffi.Change +import Foundation + +typealias FfiChange = AutomergeUniffi.Change + +public struct Change: Equatable { + public let actorId: ActorId + public let message: String? + public let deps: [ChangeHash] + public let timestamp: Date + public let bytes: Data + public let hash: ChangeHash + + init(_ ffi: FfiChange) { + actorId = ActorId(bytes: ffi.actorId) + message = ffi.message + deps = ffi.deps.map(ChangeHash.init(bytes:)) + timestamp = Date(timeIntervalSince1970: TimeInterval(ffi.timestamp)) + bytes = Data(ffi.bytes) + hash = ChangeHash(bytes: ffi.hash) + } +} diff --git a/Sources/Automerge/Document.swift b/Sources/Automerge/Document.swift index a974c383..a7520212 100644 --- a/Sources/Automerge/Document.swift +++ b/Sources/Automerge/Document.swift @@ -768,6 +768,20 @@ public final class Document: @unchecked Sendable { } } + /// Commit the auto-generated transaction with options. + /// + /// - Parameters: + /// - message: An optional message to attach to the auto-committed change (if any). + /// - timestamp: A timestamp to attach to the auto-committed change (if any), defaulting to Date(). + public func commitWith(message: String? = nil, timestamp: Date = Date()) { + sync { + self.doc.wrapErrors { + sendObjectWillChange() + $0.commitWith(msg: message, time: Int64(timestamp.timeIntervalSince1970)) + } + } + } + /// Encode the Automerge document in a compressed binary format. /// /// - Returns: The data that represents all the changes within this document. @@ -922,6 +936,16 @@ public final class Document: @unchecked Sendable { } } + /// Returns the contents of the change associated with the change hash you provide. + public func change(hash: ChangeHash) -> Change? { + sync { + guard let change = self.doc.wrapErrors(f: { $0.changeByHash(hash: hash.bytes) }) else { + return nil + } + return .init(change) + } + } + /// Get the path to an object within the document. /// /// - Parameter obj: The identifier of an array, dictionary or text object. diff --git a/Sources/_CAutomergeUniffi/include/automergeFFI.h b/Sources/_CAutomergeUniffi/include/automergeFFI.h index b4f76296..8839d382 100644 --- a/Sources/_CAutomergeUniffi/include/automergeFFI.h +++ b/Sources/_CAutomergeUniffi/include/automergeFFI.h @@ -68,8 +68,12 @@ void uniffi_uniffi_automerge_fn_method_doc_apply_encoded_changes(void*_Nonnull p ); RustBuffer uniffi_uniffi_automerge_fn_method_doc_apply_encoded_changes_with_patches(void*_Nonnull ptr, RustBuffer changes, RustCallStatus *_Nonnull out_status ); +RustBuffer uniffi_uniffi_automerge_fn_method_doc_change_by_hash(void*_Nonnull ptr, RustBuffer hash, RustCallStatus *_Nonnull out_status +); RustBuffer uniffi_uniffi_automerge_fn_method_doc_changes(void*_Nonnull ptr, RustCallStatus *_Nonnull out_status ); +void uniffi_uniffi_automerge_fn_method_doc_commit_with(void*_Nonnull ptr, RustBuffer msg, int64_t time, RustCallStatus *_Nonnull out_status +); RustBuffer uniffi_uniffi_automerge_fn_method_doc_cursor(void*_Nonnull ptr, RustBuffer obj, uint64_t position, RustCallStatus *_Nonnull out_status ); RustBuffer uniffi_uniffi_automerge_fn_method_doc_cursor_at(void*_Nonnull ptr, RustBuffer obj, uint64_t position, RustBuffer heads, RustCallStatus *_Nonnull out_status @@ -315,9 +319,15 @@ uint16_t uniffi_uniffi_automerge_checksum_method_doc_apply_encoded_changes(void ); uint16_t uniffi_uniffi_automerge_checksum_method_doc_apply_encoded_changes_with_patches(void +); +uint16_t uniffi_uniffi_automerge_checksum_method_doc_change_by_hash(void + ); uint16_t uniffi_uniffi_automerge_checksum_method_doc_changes(void +); +uint16_t uniffi_uniffi_automerge_checksum_method_doc_commit_with(void + ); uint16_t uniffi_uniffi_automerge_checksum_method_doc_cursor(void diff --git a/Tests/AutomergeTests/DocTests/AutomergeDocTests.swift b/Tests/AutomergeTests/DocTests/AutomergeDocTests.swift index c1a36fe6..b0696303 100644 --- a/Tests/AutomergeTests/DocTests/AutomergeDocTests.swift +++ b/Tests/AutomergeTests/DocTests/AutomergeDocTests.swift @@ -239,4 +239,58 @@ final class AutomergeDocTests: XCTestCase { let text = try doc.text(obj: textId) XCTAssertEqual(text, "🇬🇧😀") } + + func testCommitWith() throws { + struct Dog: Codable { + var name: String + var age: Int + } + + // Create the document + let doc = Document() + let encoder = AutomergeEncoder(doc: doc) + + // Make an initial change with a message and timestamp + var myDog = Dog(name: "Fido", age: 1) + try encoder.encode(myDog) + doc.commitWith(message: "Change 1", timestamp: Date(timeIntervalSince1970: 10)) + + // Make another change with the default timestamp + myDog.age = 2 + try encoder.encode(myDog) + doc.commitWith(message: "Change 2") + let change2Time = Date().timeIntervalSince1970 + + // Make another change with no message + myDog.age = 3 + try encoder.encode(myDog) + doc.commitWith(message: nil, timestamp: Date(timeIntervalSince1970: 20)) + + // Make another change with no message and the default timestamp + myDog.age = 4 + try encoder.encode(myDog) + doc.commitWith() + let change4Time = Date().timeIntervalSince1970 + + // Make another change by just calling save() (meaning no commit options will be set) + myDog.age = 5 + try encoder.encode(myDog) + _ = doc.save() + + let history = doc.getHistory() + XCTAssertEqual(history.count, 5) + + let changes = history.map({ doc.change(hash: $0) }) + XCTAssertEqual(changes.count, 5) + XCTAssertEqual(changes[0]!.message, "Change 1") + XCTAssertEqual(changes[0]!.timestamp, Date(timeIntervalSince1970: 10)) + XCTAssertEqual(changes[1]!.message, "Change 2") + XCTAssertEqual(changes[1]!.timestamp.timeIntervalSince1970, change2Time, accuracy: 3) + XCTAssertNil(changes[2]!.message) + XCTAssertEqual(changes[2]!.timestamp, Date(timeIntervalSince1970: 20)) + XCTAssertNil(changes[3]!.message) + XCTAssertEqual(changes[3]!.timestamp.timeIntervalSince1970, change4Time, accuracy: 3) + XCTAssertNil(changes[4]!.message) + XCTAssertEqual(changes[4]!.timestamp.timeIntervalSince1970, 0) + } } diff --git a/rust/src/automerge.udl b/rust/src/automerge.udl index 7d9703aa..9f3d79b4 100644 --- a/rust/src/automerge.udl +++ b/rust/src/automerge.udl @@ -103,6 +103,15 @@ dictionary PathElement { ObjId obj; }; +dictionary Change { + ActorId actor_id; + string? message; + sequence deps; + i64 timestamp; + sequence bytes; + ChangeHash hash; +}; + dictionary Patch { sequence path; PatchAction action; @@ -222,6 +231,10 @@ interface Doc { sequence changes(); + Change? change_by_hash(ChangeHash hash); + + void commit_with(string? msg, i64 time); + sequence save(); [Throws=DocError] diff --git a/rust/src/change.rs b/rust/src/change.rs index 982b87ca..4998c03b 100644 --- a/rust/src/change.rs +++ b/rust/src/change.rs @@ -36,6 +36,7 @@ pub enum DecodeChangeError { Internal(#[from] am::LoadChangeError), } +#[allow(dead_code)] pub fn decode_change(bytes: Vec) -> Result { am::Change::try_from(bytes.as_slice()) .map(Change::from) diff --git a/rust/src/doc.rs b/rust/src/doc.rs index 6168074b..c7d081ba 100644 --- a/rust/src/doc.rs +++ b/rust/src/doc.rs @@ -7,7 +7,9 @@ use automerge::{transaction::Transactable, ReadDoc}; use crate::actor_id::ActorId; use crate::mark::{ExpandMark, Mark}; use crate::patches::Patch; -use crate::{ChangeHash, Cursor, ObjId, ObjType, PathElement, ScalarValue, SyncState, Value}; +use crate::{ + Change, ChangeHash, Cursor, ObjId, ObjType, PathElement, ScalarValue, SyncState, Value +}; #[derive(Debug, thiserror::Error)] pub enum DocError { @@ -495,6 +497,16 @@ impl Doc { }) } + pub fn commit_with(&self, message: Option, time: i64) { + let mut doc = self.0.write().unwrap(); + let mut options = automerge::transaction::CommitOptions::default(); + options.set_time(time); + if let Some(message) = message { + options.set_message(message); + } + doc.commit_with(options); + } + pub fn save(&self) -> Vec { let mut doc = self.0.write().unwrap(); doc.save() @@ -573,6 +585,12 @@ impl Doc { changes.into_iter().map(|h| h.hash().into()).collect() } + pub fn change_by_hash(&self, hash: ChangeHash) -> Option { + let doc = self.0.write().unwrap(); + doc.get_change_by_hash(&am::ChangeHash::from(hash)) + .map(|m| Change::from(m.clone())) + } + pub fn path(&self, obj: ObjId) -> Result, DocError> { let doc = self.0.read().unwrap(); let obj = am::ObjId::from(obj); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6733f0e9..3484ae7c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,6 +4,8 @@ mod actor_id; use actor_id::ActorId; mod cursor; use cursor::Cursor; +mod change; +use change::Change; mod change_hash; use change_hash::ChangeHash; mod doc;