diff --git a/Sources/Loro/Ephemeral.swift b/Sources/Loro/Ephemeral.swift new file mode 100644 index 0000000..3eb18bd --- /dev/null +++ b/Sources/Loro/Ephemeral.swift @@ -0,0 +1,45 @@ +// +// Ephemeral.swift +// +// +// Created by Leon Zhao on 2025/6/4. +// + +import Foundation + +class ClosureEphemeralSubscriber: EphemeralSubscriber { + private let closure: (EphemeralStoreEvent) -> Void + + public init(closure: @escaping (EphemeralStoreEvent) -> Void) { + self.closure = closure + } + + public func onEphemeralEvent(event: EphemeralStoreEvent) { + closure(event) + } +} + +class ClosureLocalEphemeralListener:LocalEphemeralListener{ + + private let closure: (Data) -> Void + + public init(closure: @escaping (Data) -> Void) { + self.closure = closure + } + + public func onEphemeralUpdate(update: Data) { + closure(update) + } +} + +extension EphemeralStore{ + public func subscribe(cb: @escaping (EphemeralStoreEvent) -> Void) -> Subscription{ + let closureSubscriber = ClosureEphemeralSubscriber(closure: cb) + return self.subscribe(listener: closureSubscriber) + } + + public func subscribeLocalUpdate(cb: @escaping (Data) -> Void) -> Subscription{ + let closureListener = ClosureLocalEphemeralListener(closure: cb) + return self.subscribeLocalUpdate(listener: closureListener) + } +} diff --git a/Sources/Loro/LoroFFI.swift b/Sources/Loro/LoroFFI.swift index 16a76cc..0bbeee4 100644 --- a/Sources/Loro/LoroFFI.swift +++ b/Sources/Loro/LoroFFI.swift @@ -3261,6 +3261,17 @@ public protocol LoroDocProtocol : AnyObject { */ func configTextStyle(textStyle: StyleConfigMap) + /** + * Delete all content from a root container and hide it from the document. + * + * When a root container is empty and hidden: + * - It won't show up in `get_deep_value()` results + * - It won't be included in document snapshots + * + * Only works on root containers (containers without parents). + */ + func deleteRootContainer(cid: ContainerId) + /** * Force the document enter the detached mode. * @@ -3653,6 +3664,11 @@ public protocol LoroDocProtocol : AnyObject { */ func setChangeMergeInterval(interval: Int64) + /** + * Set whether to hide empty root containers. + */ + func setHideEmptyRootContainers(hide: Bool) + /** * Set commit message for the current uncommitted changes * @@ -4022,6 +4038,22 @@ open func configTextStyle(textStyle: StyleConfigMap) {try! rustCall() { FfiConverterTypeStyleConfigMap.lower(textStyle),$0 ) } +} + + /** + * Delete all content from a root container and hide it from the document. + * + * When a root container is empty and hidden: + * - It won't show up in `get_deep_value()` results + * - It won't be included in document snapshots + * + * Only works on root containers (containers without parents). + */ +open func deleteRootContainer(cid: ContainerId) {try! rustCall() { + uniffi_loro_fn_method_lorodoc_delete_root_container(self.uniffiClonePointer(), + FfiConverterTypeContainerID.lower(cid),$0 + ) +} } /** @@ -4702,6 +4734,16 @@ open func setChangeMergeInterval(interval: Int64) {try! rustCall() { FfiConverterInt64.lower(interval),$0 ) } +} + + /** + * Set whether to hide empty root containers. + */ +open func setHideEmptyRootContainers(hide: Bool) {try! rustCall() { + uniffi_loro_fn_method_lorodoc_set_hide_empty_root_containers(self.uniffiClonePointer(), + FfiConverterBool.lower(hide),$0 + ) +} } /** @@ -13002,6 +13044,8 @@ public enum LoroError { case ContainersNotFound(message: String) + case UndoGroupAlreadyStarted(message: String) + } @@ -13166,6 +13210,10 @@ public struct FfiConverterTypeLoroError: FfiConverterRustBuffer { message: try FfiConverterString.read(from: &buf) ) + case 38: return .UndoGroupAlreadyStarted( + message: try FfiConverterString.read(from: &buf) + ) + default: throw UniffiInternalError.unexpectedEnumCase } @@ -13251,6 +13299,8 @@ public struct FfiConverterTypeLoroError: FfiConverterRustBuffer { writeInt(&buf, Int32(36)) case .ContainersNotFound(_ /* message is ignored*/): writeInt(&buf, Int32(37)) + case .UndoGroupAlreadyStarted(_ /* message is ignored*/): + writeInt(&buf, Int32(38)) } @@ -15499,6 +15549,9 @@ private var initializationResult: InitializationResult = { if (uniffi_loro_checksum_method_lorodoc_config_text_style() != 52393) { return InitializationResult.apiChecksumMismatch } + if (uniffi_loro_checksum_method_lorodoc_delete_root_container() != 40125) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_loro_checksum_method_lorodoc_detach() != 61399) { return InitializationResult.apiChecksumMismatch } @@ -15652,6 +15705,9 @@ private var initializationResult: InitializationResult = { if (uniffi_loro_checksum_method_lorodoc_set_change_merge_interval() != 55133) { return InitializationResult.apiChecksumMismatch } + if (uniffi_loro_checksum_method_lorodoc_set_hide_empty_root_containers() != 34137) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_loro_checksum_method_lorodoc_set_next_commit_message() != 18940) { return InitializationResult.apiChecksumMismatch } diff --git a/Tests/LoroTests/EphemeralStoreTests.swift b/Tests/LoroTests/EphemeralStoreTests.swift new file mode 100644 index 0000000..952303f --- /dev/null +++ b/Tests/LoroTests/EphemeralStoreTests.swift @@ -0,0 +1,244 @@ +import XCTest +@testable import Loro + +final class EphemeralStoreTests: XCTestCase { + + func testBasicSetAndGet() { + let store = EphemeralStore(timeout: 60000) + + // Test basic set and get + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: 42) + store.set(key: "key3", value: true) + + XCTAssertEqual(store.get(key: "key1"), LoroValue.string(value: "value1")) + XCTAssertEqual(store.get(key: "key2"), LoroValue.i64(value: Int64(42))) + XCTAssertEqual(store.get(key: "key3"), LoroValue.bool(value: true)) + XCTAssertNil(store.get(key: "nonexistent")) + } + + func testKeys() { + let store = EphemeralStore(timeout: 60000) + + // Initial state should have no keys + XCTAssertEqual(store.keys().count, 0) + + // Add some keys + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: "value2") + store.set(key: "key3", value: "value3") + + let keys = store.keys() + XCTAssertEqual(keys.count, 3) + XCTAssertTrue(keys.contains("key1")) + XCTAssertTrue(keys.contains("key2")) + XCTAssertTrue(keys.contains("key3")) + } + + func testGetAllStates() { + let store = EphemeralStore(timeout: 60000) + + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: 42) + + let allStates = store.getAllStates() + XCTAssertEqual(allStates.count, 2) + XCTAssertEqual(allStates["key1"], LoroValue.string(value: "value1")) + XCTAssertEqual(allStates["key2"], LoroValue.i64(value: Int64(42))) + } + + func testDelete() { + let store = EphemeralStore(timeout: 60000) + + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: "value2") + + XCTAssertNotNil(store.get(key: "key1")) + + store.delete(key: "key1") + + XCTAssertNil(store.get(key: "key1")) + XCTAssertNotNil(store.get(key: "key2")) + + let keys = store.keys() + XCTAssertEqual(keys.count, 1) + XCTAssertFalse(keys.contains("key1")) + XCTAssertTrue(keys.contains("key2")) + } + + func testEphemeralEventSubscription() { + let store = EphemeralStore(timeout: 60000) + + var receivedEvents: [EphemeralStoreEvent] = [] + + // Subscribe to events + let subscription = store.subscribe { event in + receivedEvents.append(event) + } + + // Adding a key should trigger an event + store.set(key: "key1", value: "value1") + + // Wait a short time for event processing to complete + let expectation = XCTestExpectation(description: "Event received") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + XCTAssertGreaterThan(receivedEvents.count, 0) + + let lastEvent = receivedEvents.last! + XCTAssertEqual(lastEvent.by, .local) + XCTAssertTrue(lastEvent.added.contains("key1")) + + // Clear received events + receivedEvents.removeAll() + + // Updating a key should trigger an event + store.set(key: "key1", value: "updated_value") + + // Wait for event + let updateExpectation = XCTestExpectation(description: "Update event received") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + updateExpectation.fulfill() + } + wait(for: [updateExpectation], timeout: 1.0) + + XCTAssertGreaterThan(receivedEvents.count, 0) + let updateEvent = receivedEvents.last! + XCTAssertEqual(updateEvent.by, .local) + XCTAssertTrue(updateEvent.updated.contains("key1")) + + // Clear received events + receivedEvents.removeAll() + + // Deleting a key should trigger an event + store.delete(key: "key1") + + // Wait for event + let deleteExpectation = XCTestExpectation(description: "Delete event received") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + deleteExpectation.fulfill() + } + wait(for: [deleteExpectation], timeout: 1.0) + + XCTAssertGreaterThan(receivedEvents.count, 0) + let deleteEvent = receivedEvents.last! + XCTAssertEqual(deleteEvent.by, .local) + XCTAssertTrue(deleteEvent.removed.contains("key1")) + + // Unsubscribe + subscription.detach() + } + + func testLocalUpdateSubscription() { + let store = EphemeralStore(timeout: 60000) + + var receivedUpdates: [Data] = [] + + // Subscribe to local updates + let subscription = store.subscribeLocalUpdate { updateData in + receivedUpdates.append(updateData) + } + + // Set some data + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: 42) + + // Wait for update events + let expectation = XCTestExpectation(description: "Local update received") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Should receive update data + XCTAssertGreaterThan(receivedUpdates.count, 0) + + // Verify data is not empty + for updateData in receivedUpdates { + XCTAssertGreaterThan(updateData.count, 0) + } + + // Unsubscribe + subscription.detach() + } + + func testEncodeAndApply() { + let store1 = EphemeralStore(timeout: 60000) + let store2 = EphemeralStore(timeout: 60000) + + // Set data in store1 + store1.set(key: "key1", value: "value1") + store1.set(key: "key2", value: 42) + + // Encode all data + let encodedData = store1.encodeAll() + + // Apply data to store2 + store2.apply(data: encodedData) + + // Verify store2 has the same data + XCTAssertEqual(store2.get(key: "key1"), LoroValue.string(value: "value1")) + XCTAssertEqual(store2.get(key: "key2"), LoroValue.i64(value: Int64(42))) + + let store2Keys = store2.keys() + XCTAssertEqual(store2Keys.count, 2) + XCTAssertTrue(store2Keys.contains("key1")) + XCTAssertTrue(store2Keys.contains("key2")) + } + + func testEncodeSpecificKey() { + let store = EphemeralStore(timeout: 60000) + + store.set(key: "key1", value: "value1") + store.set(key: "key2", value: "value2") + + // Encode specific key + let encodedKey1 = store.encode(key: "key1") + XCTAssertGreaterThan(encodedKey1.count, 0) + + // Create new store and apply specific key data + let newStore = EphemeralStore(timeout: 60000) + newStore.apply(data: encodedKey1) + + // Should only have key1, not key2 + XCTAssertNotNil(newStore.get(key: "key1")) + XCTAssertNil(newStore.get(key: "key2")) + } + + func testMultipleSubscriptions() { + let store = EphemeralStore(timeout: 60000) + + var events1: [EphemeralStoreEvent] = [] + var events2: [EphemeralStoreEvent] = [] + + // Create two subscriptions + let subscription1 = store.subscribe { event in + events1.append(event) + } + + let subscription2 = store.subscribe { event in + events2.append(event) + } + + // Set data + store.set(key: "test", value: "value") + + // Wait for events + let expectation = XCTestExpectation(description: "Multiple subscriptions received") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + expectation.fulfill() + } + wait(for: [expectation], timeout: 1.0) + + // Both subscriptions should receive events + XCTAssertGreaterThan(events1.count, 0) + XCTAssertGreaterThan(events2.count, 0) + + // Unsubscribe + subscription1.detach() + subscription2.detach() + } +} \ No newline at end of file diff --git a/Tests/LoroTests/LoroTests.swift b/Tests/LoroTests/LoroTests.swift index 952001a..266aea6 100644 --- a/Tests/LoroTests/LoroTests.swift +++ b/Tests/LoroTests/LoroTests.swift @@ -111,4 +111,33 @@ final class LoroTests: XCTestCase { let s = text.toString() XCTAssertEqual(s, "bcdef") } + + func testOrigin(){ + do{ + let localDoc = LoroDoc() + let remoteDoc = LoroDoc() + try localDoc.setPeerId(peer: 1) + let localMap = localDoc.getMap(id: "properties") + try localMap.insert(key: "x", v: "42") + + // Take a snapshot of the localDoc's content. + let snapshot = try localDoc.exportSnapshot() + + // Set up and watch for changes in an initially empty remoteDoc. + try remoteDoc.setPeerId(peer: 2) + let expectedOriginString = "expectedOriginString" + let subscription = remoteDoc.subscribeRoot { event in + // Apparent bug: The event carries an empty origin string, instead of the origin string we passed into importWith(bytes:origin:). + print("Got event for remoteDoc, with event.origin=\"\(event.origin)\"") + if event.origin != expectedOriginString { + XCTFail("Expected origin '\(expectedOriginString)' but got '\(event.origin)'") + } + } + // Import the snapshot into a new LoroDoc, specifying an origin string. THis should the closure we registeredd with subscribeRoot, above, to be invoked. + let _ = try remoteDoc.importWith(bytes: snapshot, origin: expectedOriginString) + subscription.detach() + }catch { + print("ERROR: \(error)") + } + } } diff --git a/loro-rs/Cargo.lock b/loro-rs/Cargo.lock index dadd117..e1b6959 100644 --- a/loro-rs/Cargo.lock +++ b/loro-rs/Cargo.lock @@ -681,8 +681,9 @@ dependencies = [ [[package]] name = "loro" -version = "1.5.1" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a861e6a012edc8deb08a8d09c4d9eb78e5523011f2f2ab7bf878bcacd8debbfd" dependencies = [ "enum-as-inner 0.6.1", "fxhash", @@ -696,8 +697,9 @@ dependencies = [ [[package]] name = "loro-common" -version = "1.4.7" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b84787030d7925d43f125841d3ce7e4850ffac80d2687cdaa9fd571e67129b" dependencies = [ "arbitrary", "enum-as-inner 0.6.1", @@ -713,8 +715,9 @@ dependencies = [ [[package]] name = "loro-delta" -version = "1.5.0" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b920ad564430b2e392ac6f0e8f97ef5745960cc40edc482be57a407db1c243" dependencies = [ "arrayvec", "enum-as-inner 0.5.1", @@ -724,8 +727,9 @@ dependencies = [ [[package]] name = "loro-ffi" -version = "1.5.0" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4964187ec4d1eb50da5f0a6b162689834d5d2f9f9bcd6c0941f20791d6d442" dependencies = [ "loro", "serde_json", @@ -733,8 +737,9 @@ dependencies = [ [[package]] name = "loro-internal" -version = "1.5.1" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1467b6b499e3d41909cbf524a5000bcbbc21af1205d2ee20290bed140395124b" dependencies = [ "append-only-bytes", "arref", @@ -776,8 +781,9 @@ dependencies = [ [[package]] name = "loro-kv-store" -version = "1.4.7" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +version = "1.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa1e5fe58c0245e62775784b6ac69cbe04a081f3348ec3b9742ecc7901085c18" dependencies = [ "bytes", "ensure-cov", @@ -793,7 +799,8 @@ dependencies = [ [[package]] name = "loro-rle" version = "1.2.7" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "077b51e4eac04e7f18574f7e6ac0201ffd98ff65015343eee3702bc3e7c49dc8" dependencies = [ "append-only-bytes", "num", @@ -817,7 +824,8 @@ checksum = "3f3d053a135388e6b1df14e8af1212af5064746e9b87a06a345a7a779ee9695a" [[package]] name = "loro_fractional_index" version = "1.2.7" -source = "git+https://github.com/loro-dev/loro.git?tag=loro-ffi%401.5.0#fe469f8ee60c12c3b88e0c5c37d39921e89460ca" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e387a04d7d6a74ebb3434c906c84ddcae4413e5ef8bcd8e7d5138959d416a512" dependencies = [ "once_cell", "rand", diff --git a/loro-rs/Cargo.toml b/loro-rs/Cargo.toml index 59e0ab6..5a44e4e 100644 --- a/loro-rs/Cargo.toml +++ b/loro-rs/Cargo.toml @@ -14,7 +14,7 @@ path = "src/uniffi-bindgen.rs" [dependencies] -loro-ffi = { git = "https://github.com/loro-dev/loro.git", tag = "loro-ffi@1.5.0" } +loro-ffi = "1.5.8" # loro-ffi = { path = "../../loro/crates/loro-ffi" } uniffi = { version = "0.28.3" } diff --git a/loro-rs/src/loro.udl b/loro-rs/src/loro.udl index ad72b50..a5512a4 100644 --- a/loro-rs/src/loro.udl +++ b/loro-rs/src/loro.udl @@ -618,6 +618,22 @@ interface LoroDoc{ /// The callback will be called when the changes are committed but not yet applied to the OpLog. /// You can modify the commit message and timestamp in the callback by [`ChangeModifier`]. Subscription subscribe_pre_commit(PreCommitCallback callback); + + // TODO: version range + // [Throws=LoroError] + // string redact_json_updates([ByRef] string json, VersionRange version_range); + + /// Set whether to hide empty root containers. + void set_hide_empty_root_containers(boolean hide); + + /// Delete all content from a root container and hide it from the document. + /// + /// When a root container is empty and hidden: + /// - It won't show up in `get_deep_value()` results + /// - It won't be included in document snapshots + /// + /// Only works on root containers (containers without parents). + void delete_root_container(ContainerID cid); }; dictionary ContainerPath{ @@ -1518,6 +1534,9 @@ interface UndoManager{ /// Whether the undo manager can redo. boolean can_redo(); + // TODO: undo count + // TODO: redo count + /// If a local event's origin matches the given prefix, it will not be recorded in the /// undo stack. void add_exclude_origin_prefix([ByRef] string prefix); @@ -1818,6 +1837,7 @@ enum LoroError { "ConcurrentOpsWithSamePeerID", "InvalidPeerID", "ContainersNotFound", + "UndoGroupAlreadyStarted", }; [Error]