From 22206a0688ae5c27361ff2c2adde269e1b821aa3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 01:41:20 +0700 Subject: [PATCH 1/8] feat(core): map macOS library records and add Swift bindings --- .github/workflows/core.yml | 26 + .github/workflows/macos-core-verify.yml | 49 ++ Cargo.lock | 239 +++++ Cargo.toml | 3 + apple/AGENTS.md | 1 + apple/Packages/TableProFFI/.gitignore | 3 + apple/Packages/TableProFFI/Package.swift | 30 + .../CloudLibraryRecordsTests.swift | 130 +++ apple/scripts/build-core.sh | 59 ++ .../probes/check-library-name-order.sh | 39 + core/AGENTS.md | 4 +- core/ARCHITECTURE.md | 11 +- core/crates/bindgen/Cargo.toml | 17 + core/crates/bindgen/src/main.rs | 3 + core/crates/ffi/Cargo.toml | 33 +- core/crates/ffi/src/cloud.rs | 144 +++ core/crates/ffi/src/drivers.rs | 12 +- core/crates/ffi/src/lib.rs | 2 + core/crates/ffi/src/library.rs | 19 +- core/crates/ffi/tests/cloud_library.rs | 229 +++++ core/crates/ffi/tests/library.rs | 1 + core/crates/ffi/tests/sqlite_path.rs | 1 + core/crates/ffi/uniffi.toml | 5 + core/crates/storage/Cargo.toml | 4 + .../proptest-regressions/cloudkit/tests.txt | 7 + core/crates/storage/src/cloudkit/merge.rs | 174 ++++ core/crates/storage/src/cloudkit/mod.rs | 360 ++++++++ core/crates/storage/src/cloudkit/order.rs | 161 ++++ core/crates/storage/src/cloudkit/tests.rs | 830 ++++++++++++++++++ core/crates/storage/src/cloudkit/values.rs | 201 +++++ core/crates/storage/src/connections/cloud.rs | 32 + core/crates/storage/src/connections/mod.rs | 3 + .../storage/src/connections/placement.rs | 2 +- core/crates/storage/src/lib.rs | 1 + core/docs/decisions/0009-macos-on-the-core.md | 99 +++ core/docs/decisions/README.md | 1 + scripts/ci/detect-changed-paths.sh | 4 +- scripts/ci/test_detect_changed_paths.py | 6 + 38 files changed, 2918 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/macos-core-verify.yml create mode 100644 apple/Packages/TableProFFI/.gitignore create mode 100644 apple/Packages/TableProFFI/Package.swift create mode 100644 apple/Packages/TableProFFI/Tests/TableProFFITests/CloudLibraryRecordsTests.swift create mode 100755 apple/scripts/build-core.sh create mode 100755 apple/scripts/probes/check-library-name-order.sh create mode 100644 core/crates/bindgen/Cargo.toml create mode 100644 core/crates/bindgen/src/main.rs create mode 100644 core/crates/ffi/src/cloud.rs create mode 100644 core/crates/ffi/tests/cloud_library.rs create mode 100644 core/crates/storage/proptest-regressions/cloudkit/tests.txt create mode 100644 core/crates/storage/src/cloudkit/merge.rs create mode 100644 core/crates/storage/src/cloudkit/mod.rs create mode 100644 core/crates/storage/src/cloudkit/order.rs create mode 100644 core/crates/storage/src/cloudkit/tests.rs create mode 100644 core/crates/storage/src/cloudkit/values.rs create mode 100644 core/crates/storage/src/connections/cloud.rs create mode 100644 core/docs/decisions/0009-macos-on-the-core.md diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 8a0f675732..dd8c9f34c5 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -63,3 +63,29 @@ jobs: -p tablepro-driver-postgres -p tablepro-driver-redis -p tablepro-driver-sqlite - name: Core unit tests run: cargo test --locked --target "$TARGET" -p tablepro-core --lib + + swift: + name: Core (Swift bindings) + runs-on: macos-15 + # Release with fat LTO, without the engines (core/docs/decisions/0009-macos-on-the-core.md). + timeout-minutes: 90 + env: + RUSTFLAGS: -D warnings + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: "1.98" + targets: aarch64-apple-darwin + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: swift-bindings + - name: Build the static library and its Swift bindings + run: apple/scripts/build-core.sh arm64 + - name: Swift binding tests + run: swift test --package-path apple/Packages/TableProFFI diff --git a/.github/workflows/macos-core-verify.yml b/.github/workflows/macos-core-verify.yml new file mode 100644 index 0000000000..bbb7ef7a14 --- /dev/null +++ b/.github/workflows/macos-core-verify.yml @@ -0,0 +1,49 @@ +name: macOS core lane verification +on: + push: + branches: [feat/macos-core-library] + paths: [.github/workflows/macos-core-verify.yml] +permissions: + contents: read +jobs: + verify: + runs-on: macos-15 + timeout-minutes: 90 + env: + RUSTFLAGS: -D warnings + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 + with: + toolchain: "1.98" + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + with: + key: macos-core-lane + - name: Rust tests and lint + run: | + cargo test --locked -p tablepro-storage + cargo test --locked -p tablepro-ffi --no-default-features --test cloud_library + cargo clippy --locked -p tablepro-storage -p tablepro-ffi -p tablepro-bindgen --no-default-features --all-targets -- -D warnings + - name: Generate Windows bindings + run: | + cargo build --locked -p tablepro-ffi --no-default-features + cargo install uniffi-bindgen-cs --locked --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.11.1+v0.31.0 + uniffi-bindgen-cs --library target/debug/libtablepro_ffi.dylib --config core/crates/ffi/uniffi.toml --no-format --out-dir windows/src/TablePro.Interop/Generated + cargo run --locked -p tablepro-ffi --no-default-features --example csbindgen -- windows/src/TablePro.Interop/Generated/WindowMethods.g.cs + - name: Swift library and tests + run: | + apple/scripts/build-core.sh both + .claude/skills/fix-issue/scripts/verify.sh --root "$PWD" --no-wait package TableProFFI + apple/scripts/probes/check-library-name-order.sh + du -h apple/Packages/TableProFFI/Build/lib/libtablepro_ffi.a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + if: always() + with: + name: macos-core-verification + path: | + windows/src/TablePro.Interop/Generated + .analysis/feat-macos-core-library/logs diff --git a/Cargo.lock b/Cargo.lock index 3b0ed771fb..08b3d39911 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,6 +58,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -270,6 +276,48 @@ dependencies = [ "zbus", ] +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.119", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + [[package]] name = "asn1-rs" version = "0.7.2" @@ -553,6 +601,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bindgen" version = "0.71.1" @@ -865,6 +922,32 @@ name = "camino" version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] [[package]] name = "cast" @@ -971,6 +1054,45 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "clickhouse" version = "0.15.2" @@ -2443,6 +2565,17 @@ dependencies = [ "system-deps 9.0.0", ] +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + [[package]] name = "graphene-rs" version = "0.22.8" @@ -4388,6 +4521,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "portable-atomic" version = "1.15.0" @@ -5155,6 +5294,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "scylla" version = "1.9.0" @@ -5270,6 +5429,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -5538,6 +5701,12 @@ dependencies = [ "serde", ] +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "snap" version = "1.1.2" @@ -6084,6 +6253,13 @@ dependencies = [ "tracing", ] +[[package]] +name = "tablepro-bindgen" +version = "0.0.0" +dependencies = [ + "uniffi", +] + [[package]] name = "tablepro-core" version = "0.0.0" @@ -6467,6 +6643,7 @@ version = "0.0.0" dependencies = [ "async-trait", "chrono", + "proptest", "secrecy", "serde", "serde_json", @@ -6475,6 +6652,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "unicode-normalization", "uuid", ] @@ -6605,6 +6783,16 @@ dependencies = [ "testcontainers", ] +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -7209,11 +7397,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46eefd5468602930da46b1f49d3448c6dfc2e81295f93120f23f8174fd70267f" dependencies = [ "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", "uniffi_core", "uniffi_macros", "uniffi_pipeline", ] +[[package]] +name = "uniffi_bindgen" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a0c9b375d32e1365cdb2bdd7cb495eecf6fac851ddbad077412b4ee1888514" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap 2.14.2", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml 0.9.12+spec-1.1.0", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + [[package]] name = "uniffi_core" version = "0.31.2" @@ -7281,6 +7499,18 @@ dependencies = [ "uniffi_internal_macros", ] +[[package]] +name = "uniffi_udl" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a1d0a0252ce1af9e8ce78ba67ac0d8937fb2bedaf10cbddd43d3614d06ec6" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + [[package]] name = "untrusted" version = "0.7.1" @@ -7531,6 +7761,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "whoami" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index 9086e558f4..c866b5eff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "core/crates/ai", + "core/crates/bindgen", "core/crates/core", "core/crates/ffi", "core/crates/mcp", @@ -95,6 +96,8 @@ csv = "1.4" # table turns that off itself (force_no_tty). comfy-table = "8.0" unicode-width = "0.2" +# Folds accents out of a name for the Finder-like order the Apple apps sort a library by. +unicode-normalization = "0.1.25" # No zlib feature: it pulls libz-sys, and the pure-Rust deflate keeps # the build free of another C dependency. Constant memory lets a sheet # spill its rows to a temporary file instead of holding them. diff --git a/apple/AGENTS.md b/apple/AGENTS.md index 124926a0ce..60dd37a70c 100644 --- a/apple/AGENTS.md +++ b/apple/AGENTS.md @@ -28,6 +28,7 @@ Every capability is core, a module or a plugin. The first rule that fits decides - `Plugins/`: drivers and import/export formats as `.tableplugin` bundles, plus `TableProPluginKit`. Bundled plugins are the `copy: { destination: plugins }` targets in `project.yml`; the app never offers a registry install or update over one. - `Packages/`: `TableProCore` (both apps), `TableProMacCore` (macOS only, never in the iOS project), `TableProOracle`, `TableProEditor`, `TableProGrammars`. Edit PluginKit in `Plugins/TableProPluginKit/` only; `TableProCore` symlinks it. Never pass `SWIFT_VERSION=` to `xcodebuild`. - `Native/`: Rust and Go bridges. `Libs/`: static libraries from `scripts/download-libs.sh`, not in git. +- `Packages/TableProFFI`: the shared Rust core (`core/`) as the module `TableProFFI`. `scripts/build-core.sh` builds its XCFramework and generates its Swift from the same library, neither in git; then `swift test --package-path Packages/TableProFFI`. Nothing in the app links it yet (`core/docs/decisions/0009-macos-on-the-core.md`). - XcodeGen generates `TablePro.xcodeproj` and `TableProMobile/TableProMobile.xcodeproj`; never hand-edit them. Signing overrides go in `Configs/Secrets.xcconfig`. - Architecture lives in `.claude/rules/`: `plugin-system.md`, `connection-window.md`, `editor.md`, `theme.md` and `data-sync-storage.md`. diff --git a/apple/Packages/TableProFFI/.gitignore b/apple/Packages/TableProFFI/.gitignore new file mode 100644 index 0000000000..7525122b51 --- /dev/null +++ b/apple/Packages/TableProFFI/.gitignore @@ -0,0 +1,3 @@ +# Written by scripts/build-core.sh from the core it builds. +/Build/ +/Sources/TableProFFI/TableProFFI.swift diff --git a/apple/Packages/TableProFFI/Package.swift b/apple/Packages/TableProFFI/Package.swift new file mode 100644 index 0000000000..a317a8d50e --- /dev/null +++ b/apple/Packages/TableProFFI/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// The shared Rust core as the Apple apps link it. scripts/build-core.sh builds Build/TableProFFIC.xcframework and the +// generated Sources/TableProFFI/TableProFFI.swift, neither in git (core/docs/decisions/0009-macos-on-the-core.md). +// No NonisolatedNonsendingByDefault here: with it the generated async calls poll on the caller's actor, the main one. +let package = Package( + name: "TableProFFI", + platforms: [ + .macOS(.v13) + ], + products: [ + .library(name: "TableProFFI", targets: ["TableProFFI"]) + ], + targets: [ + .binaryTarget(name: "TableProFFIC", path: "Build/TableProFFIC.xcframework"), + .target( + name: "TableProFFI", + dependencies: ["TableProFFIC"], + linkerSettings: [ + .linkedFramework("CoreFoundation"), + .linkedFramework("Security"), + .linkedFramework("SystemConfiguration"), + .linkedLibrary("iconv") + ] + ), + .testTarget(name: "TableProFFITests", dependencies: ["TableProFFI"]) + ] +) diff --git a/apple/Packages/TableProFFI/Tests/TableProFFITests/CloudLibraryRecordsTests.swift b/apple/Packages/TableProFFI/Tests/TableProFFITests/CloudLibraryRecordsTests.swift new file mode 100644 index 0000000000..6f48d61a94 --- /dev/null +++ b/apple/Packages/TableProFFI/Tests/TableProFFITests/CloudLibraryRecordsTests.swift @@ -0,0 +1,130 @@ +import Foundation +import TableProFFI +import Testing + +private final class NoSecrets: HostSecrets, Sendable { + func load(connectionId: String, kind: SecretKindTag) throws -> String? { nil } + func store(connectionId: String, kind: SecretKindTag, secret: String, label: String) throws {} + func relabel(connectionId: String, kind: SecretKindTag, label: String) throws {} + func delete(connectionId: String, kind: SecretKindTag) throws {} + func deleteConnection(connectionId: String) throws {} + func loadItem(item: SecretItemTag) throws -> String? { nil } + func storeItem(item: SecretItemTag, secret: String) throws {} + func deleteItem(item: SecretItemTag) throws {} +} + +private func makeLibrary() throws -> ConnectionLibrary { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let core = try Core(config: HostConfig(clientVersion: "test", storageDir: folder.path), secrets: NoSecrets()) + return try core.library() +} + +private func save(_ name: String, in library: ConnectionLibrary) async throws -> String { + let record = ConnectionRecord( + id: "", + name: name, + driverId: "postgres", + host: "db.internal", + port: 5_432, + database: "app", + username: "", + integratedAuth: false, + tls: TlsSettings(mode: .disable, caFile: nil, clientCertificate: nil, clientKey: nil, serverName: nil), + ssh: nil, + safeMode: .off, + queryTimeoutSecs: nil, + color: nil, + groupId: nil, + lastOpenedMs: nil, + tags: [] + ) + let secrets = ConnectionSecrets(databasePassword: nil, sshPassword: nil, sshPassphrase: nil) + return try await library.save(record: record, secrets: secrets).id +} + +private let emptyRecords = CloudLibraryRecords(groups: [], connections: []) + +@Test func aLibrarySendsItsRecordsAsTheMacWritesThemAndThenNothing() async throws { + let library = try makeLibrary() + let work = try await library.createGroup(name: "Work", parentId: nil) + let id = try await save("production", in: library) + try await library.moveToGroup(ids: [id], groupId: work.id, before: nil) + try await library.setFavorite(id: id, favorite: true) + + let sent = try await library.cloudEdits(base: emptyRecords) + + #expect(sent.groups == [ + CloudGroupFields( + groupId: work.id.uppercased(), name: "Work", color: "None", iconName: nil, parentId: nil, sortOrder: 0 + ) + ]) + #expect(sent.connections == [ + CloudConnectionFields( + connectionId: id.uppercased(), name: "production", groupId: work.id.uppercased(), sortOrder: 0, isFavorite: 1 + ) + ]) + #expect(try await library.cloudEdits(base: sent) == emptyRecords) +} + +@Test func aPullFilesAConnectionUnderAnotherMacsGroupAndSendsItsNameAndIconBackUnchanged() async throws { + let library = try makeLibrary() + let id = try await save("production", in: library) + let groupId = UUID().uuidString + let changed = CloudLibraryRecords( + groups: [ + CloudGroupFields( + groupId: groupId, name: "Clients\t", color: "Gray", iconName: "person.2", parentId: nil, sortOrder: 4 + ) + ], + connections: [ + CloudConnectionFields( + connectionId: id.uppercased(), name: "production", groupId: groupId, sortOrder: 9, isFavorite: 1 + ) + ] + ) + + try await library.applyCloudPull(pull: pull(base: emptyRecords, changed: changed)) + + let snapshot = try await library.snapshot() + #expect(snapshot.groups.map { $0.name } == ["Clients"]) + #expect(snapshot.groups.first?.color == "slate") + #expect(snapshot.connections.first?.groupId == groupId.lowercased()) + #expect(snapshot.favorites == [id]) + #expect(try await library.cloudEdits(base: changed) == emptyRecords) +} + +@Test func aPullKeepsThisMacsUnpushedRenameAndTakesAnotherMacsColour() async throws { + let library = try makeLibrary() + let groupId = UUID().uuidString + let base = CloudLibraryRecords( + groups: [ + CloudGroupFields(groupId: groupId, name: "Work", color: "None", iconName: nil, parentId: nil, sortOrder: 0) + ], + connections: [] + ) + try await library.applyCloudPull(pull: pull(base: emptyRecords, changed: base)) + try await library.renameGroup(id: groupId, name: "Office") + var recoloured = base + recoloured.groups[0].color = "Red" + + try await library.applyCloudPull(pull: pull(base: base, changed: recoloured)) + + let snapshot = try await library.snapshot() + #expect(snapshot.groups.map { $0.name } == ["Office"]) + #expect(snapshot.groups.first?.color == "red") + var renamed = recoloured + renamed.groups[0].name = "Office" + #expect(try await library.cloudEdits(base: recoloured) == renamed) +} + +private func pull(base: CloudLibraryRecords, changed: CloudLibraryRecords) -> CloudLibraryPull { + CloudLibraryPull( + base: base, + changed: changed, + deletedGroupIds: [], + deletedConnectionIds: [], + deletedHereGroupIds: [], + deletedHereConnectionIds: [], + favoritesOrder: nil + ) +} diff --git a/apple/scripts/build-core.sh b/apple/scripts/build-core.sh new file mode 100755 index 0000000000..6ddc105575 --- /dev/null +++ b/apple/scripts/build-core.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Builds the shared Rust core as the static library the Apple apps link, and its Swift bindings from that same +# library, into Packages/TableProFFI (core/docs/decisions/0009-macos-on-the-core.md). Neither is in git. +# +# Usage: build-core.sh [arm64|x86_64|both] both, the default, is the universal slice the app ships +set -euo pipefail + +APPLE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +REPO_ROOT="$(cd "$APPLE_DIR/.." && pwd)" +PACKAGE_DIR="$APPLE_DIR/Packages/TableProFFI" +BUILD_DIR="$PACKAGE_DIR/Build" +TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}" +ARCH="${1:-both}" +source "$APPLE_DIR/scripts/lib/deployment-target.sh" + +case "$ARCH" in + arm64) TRIPLES=(aarch64-apple-darwin) ;; + x86_64) TRIPLES=(x86_64-apple-darwin) ;; + both) TRIPLES=(aarch64-apple-darwin x86_64-apple-darwin) ;; + *) + echo "Usage: $0 [arm64|x86_64|both]" >&2 + exit 1 + ;; +esac + +archive() { + echo "$TARGET_DIR/$1/release/libtablepro_ffi.a" +} + +cd "$REPO_ROOT" +# rustup takes the toolchain from rust-toolchain.toml here. +rustup target add "${TRIPLES[@]}" + +for triple in "${TRIPLES[@]}"; do + # The crate is a cdylib for Windows; the Apple apps link a static library, without the engines no slice moves yet. + env MACOSX_DEPLOYMENT_TARGET="$DEPLOY_TARGET" cargo rustc --locked -p tablepro-ffi --lib --release \ + --no-default-features --target "$triple" --crate-type staticlib +done + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/lib" "$BUILD_DIR/include" "$PACKAGE_DIR/Sources/TableProFFI" +ARCHIVES=() +for triple in "${TRIPLES[@]}"; do + ARCHIVES+=("$(archive "$triple")") +done +lipo -create "${ARCHIVES[@]}" -output "$BUILD_DIR/lib/libtablepro_ffi.a" +lipo -info "$BUILD_DIR/lib/libtablepro_ffi.a" +verify_deployment_target "$BUILD_DIR/lib/libtablepro_ffi.a" + +# Generated from the library just built: UniFFI checks at load that the two carry the same API checksums. +cargo run --locked --quiet -p tablepro-bindgen --bin uniffi-bindgen-swift -- \ + --swift-sources --headers --modulemap --module-name TableProFFIC --modulemap-filename module.modulemap \ + "${ARCHIVES[0]}" "$BUILD_DIR/include" +mv "$BUILD_DIR/include/TableProFFI.swift" "$PACKAGE_DIR/Sources/TableProFFI/TableProFFI.swift" + +xcodebuild -create-xcframework \ + -library "$BUILD_DIR/lib/libtablepro_ffi.a" -headers "$BUILD_DIR/include" \ + -output "$BUILD_DIR/TableProFFIC.xcframework" +echo "Built $BUILD_DIR/TableProFFIC.xcframework for macOS $DEPLOY_TARGET (${TRIPLES[*]})" diff --git a/apple/scripts/probes/check-library-name-order.sh b/apple/scripts/probes/check-library-name-order.sh new file mode 100755 index 0000000000..56d11a0aa9 --- /dev/null +++ b/apple/scripts/probes/check-library-name-order.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Library name order gate. +# +# The core breaks a tie in the connection library's manual order the way the Mac's +# LibrarySorting does, with localizedStandardCompare, and pins Foundation's verdicts in +# core/crates/storage/src/cloudkit/values.rs (FOUNDATION). This asks Foundation, in English, +# whether each pair there still sorts first before second. +# +# Usage: scripts/probes/check-library-name-order.sh + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +cat > "$WORK/probe.swift" <<'SWIFT' +import Foundation + +let pairs: [(String, String)] = [ + ("é", "z"), ("a", "A"), ("a", "B"), ("Prod 9", "Prod 10"), ("e", "é"), ("é", "f"), ("work", "Work"), + ("a", "ä"), ("ss", "ß"), ("é", "Z"), ("_a", "a"), ("a b", "ab"), ("ü", "v"), ("Ø", "p"), ("1", "a"), + ("a1", "a01") +] +// localizedStandardCompare's options, in a fixed locale so the verdict does not follow the Mac's. +let options: String.CompareOptions = [.caseInsensitive, .numeric, .widthInsensitive, .forcedOrdering] +let english = Locale(identifier: "en_US") +var failed = 0 +for (first, second) in pairs { + let order = first.compare(second, options: options, range: nil, locale: english) + if order != .orderedAscending { + print("FAIL \(first) before \(second): Foundation says \(order.rawValue)") + failed += 1 + } +} +print(failed == 0 ? "PASS \(pairs.count) pairs" : "FAIL \(failed) of \(pairs.count) pairs") +exit(failed == 0 ? 0 : 1) +SWIFT + +xcrun swift "$WORK/probe.swift" diff --git a/core/AGENTS.md b/core/AGENTS.md index b3624d3834..701cfe4348 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -1,6 +1,6 @@ # TablePro shared core -The Rust crates every platform's app builds on: the domain types and the driver contract, one crate per database engine, the network and SSH transports, sessions, storage, the MCP server and the AI client. None has a GUI. The Linux app (`linux/`) uses them directly; a Windows app and the Swift app will use them later ([ADR 0001](docs/decisions/0001-shared-core.md)). +The Rust crates every platform's app builds on: the domain types and the driver contract, one crate per database engine, the network and SSH transports, sessions, storage, the MCP server and the AI client. None has a GUI. The Linux app (`linux/`) uses them directly, the Windows app through `crates/ffi`, and the macOS app through the same crate one slice at a time ([ADR 0001](docs/decisions/0001-shared-core.md), [ADR 0009](docs/decisions/0009-macos-on-the-core.md)). This is the guide for work under `core/`. The root `AGENTS.md` holds the rules every app shares. Claude Code loads this file through `core/CLAUDE.md` once a file under `core/` is read; Codex reads it when started in `core/`, so otherwise read it yourself. @@ -15,7 +15,7 @@ This is the guide for work under `core/`. The root `AGENTS.md` holds the rules e ## Map -- `crates/core`, `crates/net`, `crates/ssh`, `crates/storage`, `crates/session`, `crates/ai`, `crates/mcp`: the libraries. `crates/drivers/*`: one crate per engine. `crates/ffi`: the library the Windows app loads (`ARCHITECTURE.md`, Bindings). `crates/test-fixtures` and `crates/test-support`: test-only. +- `crates/core`, `crates/net`, `crates/ssh`, `crates/storage`, `crates/session`, `crates/ai`, `crates/mcp`: the libraries. `crates/drivers/*`: one crate per engine. `crates/ffi`: the library the Windows and Apple apps load (`ARCHITECTURE.md`, Bindings). `crates/bindgen`: the Swift binding generator. `crates/test-fixtures` and `crates/test-support`: test-only. - `ARCHITECTURE.md`: the crates, the graph, the platform seams and the driver layer. `docs/decisions/`: the cross-platform decisions. - The Cargo workspace and its tool settings are at the top of the repository: `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml`, `rustfmt.toml`, `clippy.toml`, `deny.toml`, `.config/nextest.toml`. - Until another app builds the core, the Linux docs hold its contracts: `linux/docs/adding-drivers.md`, `linux/docs/drivers/`, `linux/docs/error-handling.md`, `linux/docs/testing.md` and `linux/docs/storage.md`. diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index bc9edf10bc..db9a548295 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -The shared core is the Rust workspace's UI-free crates under `core/crates`: the domain types and the driver contract, one crate per database engine, the network and SSH transports, sessions, storage, the MCP server, the AI client, and the library other languages load. Each platform's app builds on them with a native UI: the GTK app in `linux/` directly, the Windows app in `windows/` through `ffi`, and the Swift app later ([ADR 0001](docs/decisions/0001-shared-core.md)). `linux/ARCHITECTURE.md` covers the Linux app. +The shared core is the Rust workspace's UI-free crates under `core/crates`: the domain types and the driver contract, one crate per database engine, the network and SSH transports, sessions, storage, the MCP server, the AI client, and the library other languages load. Each platform's app builds on them with a native UI: the GTK app in `linux/` directly, the Windows app in `windows/` through `ffi`, and the Swift app through `ffi` one slice at a time ([ADR 0001](docs/decisions/0001-shared-core.md), [ADR 0009](docs/decisions/0009-macos-on-the-core.md)). `linux/ARCHITECTURE.md` covers the Linux app. ## Crate layout @@ -16,6 +16,7 @@ core/crates/ ├── ai/ the AI assistant's providers, prompts and replies ├── mcp/ the MCP server: transport, token and tools ├── ffi/ the library C# and Swift load: one UniFFI API and the grid's C ABI +├── bindgen/ uniffi-bindgen-swift, which writes the Swift bindings from a built ffi ├── test-fixtures/ dev-only container fixtures with a generated PKI ├── test-support/ dev-only fakes and the conformance suites └── drivers/ @@ -41,7 +42,7 @@ core/crates/ | `session` | `core`, `net`, `ssh`, `storage` | | `mcp` | `core`, `session`, `storage` | | `ai` | `core`, `net` | -| `ffi` | `core`, `session`, `ssh` and every driver | +| `ffi` | `core`, `session`, `ssh`, `storage`, and every driver behind its default `engines` feature | | An app, such as `linux/crates/app` | every crate above, and its platform crate | Rules, enforced by review: @@ -93,15 +94,15 @@ Each boundary error has a stable `code()`, such as `driver.timeout` or `ssh.host ## Bindings -`ffi` is the library an app outside Rust loads: the Windows app now, the Swift app later. Core ADRs 0002, on the bindings, and 0003, on result windows, hold the reasons. +`ffi` is the library an app outside Rust loads: the Windows app as a DLL, the Apple apps as a static library in an XCFramework, built without the `engines` feature by `apple/scripts/build-core.sh`. Core ADRs 0002, on the bindings, 0003, on result windows, and 0009, on the macOS slices, hold the reasons. -- One UniFFI API. `Core` lists the engines with the family each is listed under, opens a file, connects a saved connection or tests one from a form, and parses a connection URL. Its `ConnectionLibrary` reads and changes the saved connections, groups, favourites and tags in `connections.json` and stores their secrets. A `DatabaseSession` reads the catalog one level at a time, writes a table's sorted page at an offset and counts the table, writes a query re-run with a sort (`docs/decisions/0007-sorting-a-result.md`), reports and waits on the session's health, and opens a `QueryEditor`. `QueryEditor::run` returns a `QueryRun` at once; the app drains its events after a `RunListener` call, which comes only when no earlier one is waiting to be drained, and the last says which transaction the editor is left in. Each result set is a `ResultSet`, whose `sorted` gives the rows it holds in a column's order. +- One UniFFI API. `Core` lists the engines with the family each is listed under, opens a file, connects a saved connection or tests one from a form, and parses a connection URL. Its `ConnectionLibrary` reads and changes the saved connections, groups, favourites and tags in `connections.json` and stores their secrets, and gives the Apple apps the library's CloudKit records and merges a pull into it (`tablepro_storage::cloudkit`). A `DatabaseSession` reads the catalog one level at a time, writes a table's sorted page at an offset and counts the table, writes a query re-run with a sort (`docs/decisions/0007-sorting-a-result.md`), reports and waits on the session's health, and opens a `QueryEditor`. `QueryEditor::run` returns a `QueryRun` at once; the app drains its events after a `RunListener` call, which comes only when no earlier one is waiting to be drained, and the last says which transaction the editor is left in. Each result set is a `ResultSet`, whose `sorted` gives the rows it holds in a column's order. - The grid reads a `ResultSet` through the C ABI in `src/window_abi.rs`: `tablepro_cursor_fetch` packs the display text of one visible range, and `tablepro_cursor_free` ends the cursor. - The library owns one tokio runtime. Each async export runs on it and is aborted when the foreign side drops the call; a `CancelToken` stops it sooner, since UniFFI cannot cancel. - A result set takes rows as the driver reads them. Each column keeps a kind byte, a raw `i64` and the exact text `tablepro_core::export::value_text` writes, or a binary value's bytes, in chunks of 65,536 rows. A window only cuts and copies. - A `CoreError` carries the `code()` of each error from the outermost in; the innermost decides. -How the bindings are generated, and where they live: `windows/AGENTS.md`, Bindings. +How the bindings are generated, and where they live: `windows/AGENTS.md`, Bindings, for C#, and `apple/AGENTS.md`, Repository map, for Swift. ## Driver layer diff --git a/core/crates/bindgen/Cargo.toml b/core/crates/bindgen/Cargo.toml new file mode 100644 index 0000000000..27e346a5f1 --- /dev/null +++ b/core/crates/bindgen/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tablepro-bindgen" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +# The generator lives apart from the FFI crate: uniffi's cli feature in the library itself makes the app's static library tens of MB larger. +[[bin]] +name = "uniffi-bindgen-swift" +path = "src/main.rs" + +[dependencies] +uniffi = { workspace = true, features = ["cli", "cargo-metadata"] } + +[lints] +workspace = true diff --git a/core/crates/bindgen/src/main.rs b/core/crates/bindgen/src/main.rs new file mode 100644 index 0000000000..b1b0d730b4 --- /dev/null +++ b/core/crates/bindgen/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_swift(); +} diff --git a/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml index 7be03a23df..0ff2e3fc8f 100644 --- a/core/crates/ffi/Cargo.toml +++ b/core/crates/ffi/Cargo.toml @@ -10,17 +10,32 @@ publish.workspace = true name = "tablepro_ffi" crate-type = ["cdylib", "lib"] +[features] +default = ["engines"] +# Every engine the core has. The Apple build leaves them out until a slice moves one (docs/decisions/0009-macos-on-the-core.md). +engines = [ + "dep:tablepro-driver-cassandra", + "dep:tablepro-driver-clickhouse", + "dep:tablepro-driver-duckdb", + "dep:tablepro-driver-mongodb", + "dep:tablepro-driver-mssql", + "dep:tablepro-driver-mysql", + "dep:tablepro-driver-postgres", + "dep:tablepro-driver-redis", + "dep:tablepro-driver-sqlite", +] + [dependencies] tablepro-core.workspace = true -tablepro-driver-cassandra.workspace = true -tablepro-driver-clickhouse.workspace = true -tablepro-driver-duckdb.workspace = true -tablepro-driver-mongodb.workspace = true -tablepro-driver-mssql.workspace = true -tablepro-driver-mysql.workspace = true -tablepro-driver-postgres.workspace = true -tablepro-driver-redis.workspace = true -tablepro-driver-sqlite.workspace = true +tablepro-driver-cassandra = { workspace = true, optional = true } +tablepro-driver-clickhouse = { workspace = true, optional = true } +tablepro-driver-duckdb = { workspace = true, optional = true } +tablepro-driver-mongodb = { workspace = true, optional = true } +tablepro-driver-mssql = { workspace = true, optional = true } +tablepro-driver-mysql = { workspace = true, optional = true } +tablepro-driver-postgres = { workspace = true, optional = true } +tablepro-driver-redis = { workspace = true, optional = true } +tablepro-driver-sqlite = { workspace = true, optional = true } tablepro-session.workspace = true tablepro-ssh.workspace = true tablepro-storage.workspace = true diff --git a/core/crates/ffi/src/cloud.rs b/core/crates/ffi/src/cloud.rs new file mode 100644 index 0000000000..9c1512827b --- /dev/null +++ b/core/crates/ffi/src/cloud.rs @@ -0,0 +1,144 @@ +use tablepro_storage::cloudkit::{CloudConnection, CloudGroup, CloudIds, CloudPull, CloudRecords}; +use uuid::Uuid; + +use crate::error::CoreError; +use crate::library::{ConnectionLibrary, parse_id}; + +// The CloudKit records of the connection library, field for field as the Apple apps sync them +// (core/docs/decisions/0009-macos-on-the-core.md). None is a field the record lacks. +#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] +pub struct CloudGroupFields { + pub group_id: String, + pub name: Option, + pub color: Option, + pub icon_name: Option, + pub parent_id: Option, + pub sort_order: Option, +} + +// The fields that place a connection; its name is read only to break a tie in the order. +#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] +pub struct CloudConnectionFields { + pub connection_id: String, + pub name: Option, + pub group_id: Option, + pub sort_order: Option, + pub is_favorite: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] +pub struct CloudLibraryRecords { + pub groups: Vec, + pub connections: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] +pub struct CloudLibraryPull { + // Every library record held before the pull, as the server last gave it. + pub base: CloudLibraryRecords, + pub changed: CloudLibraryRecords, + pub deleted_group_ids: Vec, + pub deleted_connection_ids: Vec, + // Deleted on this device and not pushed yet. + pub deleted_here_group_ids: Vec, + pub deleted_here_connection_ids: Vec, + // The favourites' order to keep when the library does not hold it yet, as on a first sync. + pub favorites_order: Option>, +} + +#[uniffi::export] +impl ConnectionLibrary { + // The records a push sends: new ones, and those with a field that differs from what the server last gave. + pub async fn cloud_edits(&self, base: CloudLibraryRecords) -> Result { + let base = CloudRecords::from(base); + self.blocking(move |store| Ok(CloudLibraryRecords::from(store.cloud_edits_blocking(&base)?))) + .await + } + + // The server's records with this device's unpushed edits over them; a deletion wins from either side. A push's + // saved copies come through here too, with the cache from before the push as the base, before they are cached. + pub async fn apply_cloud_pull(&self, pull: CloudLibraryPull) -> Result<(), CoreError> { + let pull = CloudPull { + base: pull.base.into(), + changed: pull.changed.into(), + deleted: ids(&pull.deleted_group_ids, &pull.deleted_connection_ids)?, + deleted_here: ids(&pull.deleted_here_group_ids, &pull.deleted_here_connection_ids)?, + favorites_order: pull + .favorites_order + .map(|order| order.iter().map(|id| parse_id(id)).collect::, _>>()) + .transpose()?, + }; + let deleted = pull.deleted.connections.clone(); + self.blocking(move |store| Ok(store.apply_cloud_pull_blocking(&pull)?)) + .await?; + self.delete_secrets(deleted).await + } +} + +fn ids(groups: &[String], connections: &[String]) -> Result { + let parse = |ids: &[String]| ids.iter().map(|id| parse_id(id)).collect::, _>>(); + Ok(CloudIds { + groups: parse(groups)?, + connections: parse(connections)?, + }) +} + +impl From for CloudRecords { + fn from(records: CloudLibraryRecords) -> Self { + Self { + groups: records + .groups + .into_iter() + .map(|group| CloudGroup { + group_id: group.group_id, + name: group.name, + color: group.color, + icon_name: group.icon_name, + parent_id: group.parent_id, + sort_order: group.sort_order, + }) + .collect(), + connections: records + .connections + .into_iter() + .map(|connection| CloudConnection { + connection_id: connection.connection_id, + name: connection.name, + group_id: connection.group_id, + sort_order: connection.sort_order, + is_favorite: connection.is_favorite, + }) + .collect(), + } + } +} + +impl From for CloudLibraryRecords { + fn from(records: CloudRecords) -> Self { + Self { + groups: records + .groups + .into_iter() + .map(|group| CloudGroupFields { + group_id: group.group_id, + name: group.name, + color: group.color, + icon_name: group.icon_name, + parent_id: group.parent_id, + sort_order: group.sort_order, + }) + .collect(), + connections: records + .connections + .into_iter() + .map(|connection| CloudConnectionFields { + connection_id: connection.connection_id, + name: connection.name, + group_id: connection.group_id, + sort_order: connection.sort_order, + is_favorite: connection.is_favorite, + }) + .collect(), + } + } +} diff --git a/core/crates/ffi/src/drivers.rs b/core/crates/ffi/src/drivers.rs index a2e7d0e495..902e7676ac 100644 --- a/core/crates/ffi/src/drivers.rs +++ b/core/crates/ffi/src/drivers.rs @@ -1,9 +1,15 @@ -use std::sync::Arc; - use tablepro_core::{DataModel, DatabaseDriver, DriverRegistry, EndpointKind, EngineCategory}; +#[cfg(not(feature = "engines"))] +pub(crate) fn registry() -> DriverRegistry { + DriverRegistry::new() +} + // Compiled in until the driver ABI lands (core/docs/decisions/0004-driver-plugin-abi.md). +#[cfg(feature = "engines")] pub(crate) fn registry() -> DriverRegistry { + use std::sync::Arc; + let mut registry = DriverRegistry::new(); registry.register(Arc::new(tablepro_driver_cassandra::CassandraDriver)); registry.register(Arc::new(tablepro_driver_clickhouse::ClickhouseDriver)); @@ -93,7 +99,7 @@ impl DriverInfo { } } -#[cfg(test)] +#[cfg(all(test, feature = "engines"))] mod tests { use super::*; diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs index 9780251e28..2cd7fd53e6 100644 --- a/core/crates/ffi/src/lib.rs +++ b/core/crates/ffi/src/lib.rs @@ -2,6 +2,7 @@ mod cancel_token; mod catalog; +mod cloud; mod connection_url; mod core_handle; #[cfg(windows)] @@ -20,6 +21,7 @@ pub mod window_abi; pub use cancel_token::CancelToken; pub use catalog::{ColumnEntry, ObjectEntry, ObjectKindTag, ObjectScopeArg, SchemaEntry, TableName}; +pub use cloud::{CloudConnectionFields, CloudGroupFields, CloudLibraryPull, CloudLibraryRecords}; pub use connection_url::{ParsedConnectionUrl, parse_connection_url}; pub use core_handle::{Core, FileOpenRequest}; pub use drivers::{DataModelTag, DriverInfo, EndpointInfo, EngineCategoryTag}; diff --git a/core/crates/ffi/src/library.rs b/core/crates/ffi/src/library.rs index 0c1652288c..8dc3a7af2f 100644 --- a/core/crates/ffi/src/library.rs +++ b/core/crates/ffi/src/library.rs @@ -147,7 +147,18 @@ impl ConnectionLibrary { Self { store, secrets, tasks } } - async fn blocking(&self, work: F) -> Result + pub(crate) async fn delete_secrets(&self, ids: Vec) -> Result<(), CoreError> { + let vault = self.secrets.clone(); + on_runtime(&self.tasks, async move { + for id in ids { + vault.delete_connection(id).await.map_err(secret_error)?; + } + Ok(()) + }) + .await + } + + pub(crate) async fn blocking(&self, work: F) -> Result where T: Send + 'static, F: FnOnce(&ConnectionStore) -> Result + Send + 'static, @@ -259,11 +270,7 @@ impl ConnectionLibrary { Ok(()) }) .await?; - let vault = self.secrets.clone(); - on_runtime(&self.tasks, async move { - vault.delete_connection(id).await.map_err(secret_error) - }) - .await + self.delete_secrets(vec![id]).await } pub async fn set_favorite(&self, id: String, favorite: bool) -> Result<(), CoreError> { diff --git a/core/crates/ffi/tests/cloud_library.rs b/core/crates/ffi/tests/cloud_library.rs new file mode 100644 index 0000000000..6b1c5aba12 --- /dev/null +++ b/core/crates/ffi/tests/cloud_library.rs @@ -0,0 +1,229 @@ +#![expect( + clippy::unwrap_used, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use tablepro_ffi::{ + CloudConnectionFields, CloudGroupFields, CloudLibraryPull, CloudLibraryRecords, ConnectionLibrary, + ConnectionRecord, ConnectionSecrets, Core, HostConfig, HostSecretError, HostSecrets, SafeModeTag, SecretItemTag, + SecretKindTag, TlsModeTag, TlsSettings, +}; +use tempfile::TempDir; + +type Stored = Arc>>; + +#[derive(Default, Clone)] +struct MemorySecrets(Stored); + +impl HostSecrets for MemorySecrets { + fn load(&self, connection_id: String, kind: SecretKindTag) -> Result, HostSecretError> { + Ok(self.0.lock().unwrap().get(&(connection_id, kind)).cloned()) + } + + fn store( + &self, + connection_id: String, + kind: SecretKindTag, + secret: String, + _: String, + ) -> Result<(), HostSecretError> { + self.0.lock().unwrap().insert((connection_id, kind), secret); + Ok(()) + } + + fn relabel(&self, _: String, _: SecretKindTag, _: String) -> Result<(), HostSecretError> { + Ok(()) + } + + fn delete(&self, connection_id: String, kind: SecretKindTag) -> Result<(), HostSecretError> { + self.0.lock().unwrap().remove(&(connection_id, kind)); + Ok(()) + } + + fn delete_connection(&self, connection_id: String) -> Result<(), HostSecretError> { + self.0.lock().unwrap().retain(|(id, _), _| *id != connection_id); + Ok(()) + } + + fn load_item(&self, _: SecretItemTag) -> Result, HostSecretError> { + Ok(None) + } + + fn store_item(&self, _: SecretItemTag, _: String) -> Result<(), HostSecretError> { + Ok(()) + } + + fn delete_item(&self, _: SecretItemTag) -> Result<(), HostSecretError> { + Ok(()) + } +} + +fn library(dir: &TempDir) -> Arc { + let core = Core::new( + HostConfig { + client_version: "test".to_owned(), + storage_dir: Some(dir.path().display().to_string()), + }, + Box::new(MemorySecrets::default()), + ) + .unwrap(); + core.library().unwrap() +} + +async fn saved(library: &ConnectionLibrary, name: &str) -> String { + let record = ConnectionRecord { + id: String::new(), + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db.internal".to_owned(), + port: 5432, + database: "app".to_owned(), + username: String::new(), + integrated_auth: false, + tls: TlsSettings { + mode: TlsModeTag::Disable, + ca_file: None, + client_certificate: None, + client_key: None, + server_name: None, + }, + ssh: None, + safe_mode: SafeModeTag::Off, + query_timeout_secs: None, + color: None, + group_id: None, + last_opened_ms: None, + tags: Vec::new(), + }; + library.save(record, ConnectionSecrets::default()).await.unwrap().id +} + +#[tokio::test] +async fn a_library_writes_its_records_and_then_has_nothing_to_send() { + let dir = TempDir::new().unwrap(); + let library = library(&dir); + let work = library.create_group("Work".to_owned(), None).await.unwrap(); + let id = saved(&library, "production").await; + library + .move_to_group(vec![id.clone()], Some(work.id.clone()), None) + .await + .unwrap(); + library.set_favorite(id.clone(), true).await.unwrap(); + + let sent = library.cloud_edits(CloudLibraryRecords::default()).await.unwrap(); + + assert_eq!( + sent.groups, + [CloudGroupFields { + group_id: work.id.to_uppercase(), + name: Some("Work".to_owned()), + color: Some("None".to_owned()), + icon_name: None, + parent_id: None, + sort_order: Some(0), + }] + ); + assert_eq!( + sent.connections, + [CloudConnectionFields { + connection_id: id.to_uppercase(), + name: Some("production".to_owned()), + group_id: Some(work.id.to_uppercase()), + sort_order: Some(0), + is_favorite: Some(1), + }] + ); + assert_eq!(library.cloud_edits(sent).await.unwrap(), CloudLibraryRecords::default()); +} + +#[tokio::test] +async fn a_pull_files_connections_under_the_groups_another_mac_made() { + let dir = TempDir::new().unwrap(); + let library = library(&dir); + let id = saved(&library, "production").await; + let group_id = "6F9619FF-8B86-D011-B42D-00C04FC964FF".to_owned(); + let changed = CloudLibraryRecords { + groups: vec![CloudGroupFields { + group_id: group_id.clone(), + name: Some("Clients\t".to_owned()), + color: Some("Gray".to_owned()), + icon_name: Some("person.2".to_owned()), + parent_id: None, + sort_order: Some(4), + }], + connections: vec![CloudConnectionFields { + connection_id: id.to_uppercase(), + name: Some("production".to_owned()), + group_id: Some(group_id.clone()), + sort_order: Some(9), + is_favorite: Some(1), + }], + }; + + library + .apply_cloud_pull(CloudLibraryPull { + changed: changed.clone(), + ..CloudLibraryPull::default() + }) + .await + .unwrap(); + + let snapshot = library.snapshot().await.unwrap(); + assert_eq!(snapshot.groups.len(), 1); + assert_eq!(snapshot.groups[0].name, "Clients"); + assert_eq!(snapshot.groups[0].color.as_deref(), Some("slate")); + assert_eq!(snapshot.connections[0].group_id, Some(group_id.to_lowercase())); + assert_eq!(snapshot.favorites, [id]); + assert_eq!( + library.cloud_edits(changed).await.unwrap(), + CloudLibraryRecords::default() + ); +} + +#[tokio::test] +async fn cloud_deletions_remove_secrets_only_after_storage_succeeds() { + let dir = TempDir::new().unwrap(); + let secrets = MemorySecrets::default(); + let core = Core::new( + HostConfig { + client_version: "test".to_owned(), + storage_dir: Some(dir.path().display().to_string()), + }, + Box::new(secrets.clone()), + ) + .unwrap(); + let library = core.library().unwrap(); + let id = saved(&library, "production").await; + secrets + .store( + id.clone(), + SecretKindTag::DatabasePassword, + "password".to_owned(), + "test".to_owned(), + ) + .unwrap(); + let pull = CloudLibraryPull { + deleted_connection_ids: vec![id.clone()], + ..CloudLibraryPull::default() + }; + let file = dir.path().join("connections.json"); + let contents = std::fs::read(&file).unwrap(); + std::fs::remove_file(&file).unwrap(); + std::fs::create_dir(&file).unwrap(); + assert!(library.apply_cloud_pull(pull.clone()).await.is_err()); + assert_eq!( + secrets + .load(id.clone(), SecretKindTag::DatabasePassword) + .unwrap() + .as_deref(), + Some("password") + ); + std::fs::remove_dir(&file).unwrap(); + std::fs::write(&file, contents).unwrap(); + library.apply_cloud_pull(pull).await.unwrap(); + assert!(library.snapshot().await.unwrap().connections.is_empty()); + assert_eq!(secrets.load(id, SecretKindTag::DatabasePassword).unwrap(), None); +} diff --git a/core/crates/ffi/tests/library.rs b/core/crates/ffi/tests/library.rs index 1d7495fa74..50a736f945 100644 --- a/core/crates/ffi/tests/library.rs +++ b/core/crates/ffi/tests/library.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "engines")] #![expect( clippy::unwrap_used, reason = "the setup helpers fail a test the way the test functions themselves do" diff --git a/core/crates/ffi/tests/sqlite_path.rs b/core/crates/ffi/tests/sqlite_path.rs index d6fa165ae5..13c1780d8e 100644 --- a/core/crates/ffi/tests/sqlite_path.rs +++ b/core/crates/ffi/tests/sqlite_path.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "engines")] #![expect( clippy::unwrap_used, clippy::expect_used, diff --git a/core/crates/ffi/uniffi.toml b/core/crates/ffi/uniffi.toml index 1310692956..3f7af82910 100644 --- a/core/crates/ffi/uniffi.toml +++ b/core/crates/ffi/uniffi.toml @@ -2,3 +2,8 @@ namespace = "TablePro.Interop.Native" cdylib_name = "tablepro_ffi" global_methods_class_name = "CoreMethods" + +# The Apple apps import TableProFFI; its C declarations are the TableProFFIC module in the XCFramework. +[bindings.swift] +module_name = "TableProFFI" +ffi_module_name = "TableProFFIC" diff --git a/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml index 5fbdb4fd67..763ee622de 100644 --- a/core/crates/storage/Cargo.toml +++ b/core/crates/storage/Cargo.toml @@ -14,6 +14,7 @@ serde_json.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["fs"] } uuid.workspace = true +unicode-normalization.workspace = true sqlx = { workspace = true, features = ["migrate", "macros"] } chrono.workspace = true tempfile.workspace = true @@ -26,5 +27,8 @@ sqlx = { workspace = true, features = ["sqlite-unbundled"] } [target.'cfg(not(target_os = "linux"))'.dependencies] sqlx = { workspace = true, features = ["sqlite"] } +[dev-dependencies] +proptest.workspace = true + [lints] workspace = true diff --git a/core/crates/storage/proptest-regressions/cloudkit/tests.txt b/core/crates/storage/proptest-regressions/cloudkit/tests.txt new file mode 100644 index 0000000000..537570a4f2 --- /dev/null +++ b/core/crates/storage/proptest-regressions/cloudkit/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc a3d4d65d59dd0d3797cb279b3fa496e5fa31fa6d5fe9d517f52c0370320d131b # shrinks to records = CloudRecords { groups: [CloudGroup { group_id: "18E66214-40A4-432B-BFCD-56E6ECA87325", name: Some("Work"), color: None, icon_name: None, parent_id: None, sort_order: Some(9223372036854775807) }, CloudGroup { group_id: "8E705A90-0DA5-445A-8949-947B6C3D2E7E", name: Some("Work"), color: None, icon_name: None, parent_id: None, sort_order: None }], connections: [] } diff --git a/core/crates/storage/src/cloudkit/merge.rs b/core/crates/storage/src/cloudkit/merge.rs new file mode 100644 index 0000000000..613bb0e8af --- /dev/null +++ b/core/crates/storage/src/cloudkit/merge.rs @@ -0,0 +1,174 @@ +use std::collections::{HashMap, HashSet}; + +use uuid::Uuid; + +use super::values::record_id; +use super::{CloudConnection, CloudGroup, CloudPull, CloudRecords, records}; +use crate::ConnectionList; + +/// The records the library reads after a pull: the server's, as `base` updated by the pull, with each field this +/// device changed since `base` laid over them, the same edit a push rebases onto the server's copy. A deletion +/// wins from either side, and a record this device holds no base for takes the server's copy whole. +pub fn merged(list: &ConnectionList, pull: &CloudPull) -> CloudRecords { + let local = records(list, &pull.base); + let mut groups = merge( + Side { + base: &pull.base.groups, + local: &local.groups, + changed: &pull.changed.groups, + }, + &pull.deleted.groups, + &pull.deleted_here.groups, + |record| &record.group_id, + |base, local, server| CloudGroup { + group_id: server.group_id.clone(), + name: pick(&base.name, &local.name, &server.name), + color: pick(&base.color, &local.color, &server.color), + icon_name: pick(&base.icon_name, &local.icon_name, &server.icon_name), + parent_id: pick(&base.parent_id, &local.parent_id, &server.parent_id), + sort_order: pick(&base.sort_order, &local.sort_order, &server.sort_order), + }, + ); + yield_cycles(&mut groups, pull, &local.groups); + CloudRecords { + groups, + connections: merge( + Side { + base: &pull.base.connections, + local: &local.connections, + changed: &pull.changed.connections, + }, + &pull.deleted.connections, + &pull.deleted_here.connections, + |record| &record.connection_id, + |base, local, server| CloudConnection { + connection_id: server.connection_id.clone(), + name: server.name.clone(), + group_id: pick(&base.group_id, &local.group_id, &server.group_id), + sort_order: pick(&base.sort_order, &local.sort_order, &server.sort_order), + is_favorite: pick(&base.is_favorite, &local.is_favorite, &server.is_favorite), + }, + ), + } +} + +// A move made here that closes a cycle with a move another device pushed first yields to it: a tree cannot +// hold both, and laying out the cycle would root both groups and undo the other device's move on the next push. +fn yield_cycles(groups: &mut [CloudGroup], pull: &CloudPull, local: &[CloudGroup]) { + let keyed = |records: &[CloudGroup]| -> HashMap { + records + .iter() + .filter_map(|record| record_id(&record.group_id).map(|id| (id, record.clone()))) + .collect() + }; + let base = keyed(&pull.base.groups); + let mut server = base.clone(); + server.extend(keyed(&pull.changed.groups)); + let moved_here: HashSet = keyed(local) + .into_iter() + .filter(|(id, record)| base.get(id).is_some_and(|held| held.parent_id != record.parent_id)) + .map(|(id, _)| id) + .collect(); + loop { + let parents: HashMap> = groups + .iter() + .filter_map(|group| { + let id = record_id(&group.group_id)?; + Some((id, group.parent_id.as_deref().and_then(record_id))) + }) + .collect(); + let cyclic = cyclic_ids(&parents); + let mut yielded = false; + for group in groups.iter_mut() { + let Some(id) = record_id(&group.group_id) else { + continue; + }; + if cyclic.contains(&id) && moved_here.contains(&id) { + let theirs = server.get(&id).and_then(|record| record.parent_id.clone()); + yielded |= group.parent_id != theirs; + group.parent_id = theirs; + } + } + if !yielded { + return; + } + } +} + +fn cyclic_ids(parents: &HashMap>) -> HashSet { + let mut cyclic = HashSet::new(); + let mut settled = HashSet::new(); + for &start in parents.keys() { + let mut path: Vec = Vec::new(); + let mut current = Some(start); + while let Some(id) = current { + if settled.contains(&id) || cyclic.contains(&id) { + break; + } + if let Some(at) = path.iter().position(|seen| *seen == id) { + cyclic.extend(path[at..].iter().copied()); + break; + } + path.push(id); + current = parents.get(&id).copied().flatten(); + } + settled.extend(path.into_iter().filter(|id| !cyclic.contains(id))); + } + cyclic +} + +struct Side<'a, R> { + base: &'a [R], + local: &'a [R], + changed: &'a [R], +} + +fn pick(base: &T, local: &T, server: &T) -> T { + if local == base { server.clone() } else { local.clone() } +} + +fn merge( + side: Side<'_, R>, + deleted: &[Uuid], + deleted_here: &[Uuid], + id: impl Fn(&R) -> &String, + fields: impl Fn(&R, &R, &R) -> R, +) -> Vec { + let deleted: HashSet<&Uuid> = deleted.iter().chain(deleted_here).collect(); + let mut server: Vec = Vec::with_capacity(side.base.len() + side.changed.len()); + let mut at: HashMap = HashMap::with_capacity(server.capacity()); + for record in side.base.iter().chain(side.changed) { + let Some(key) = record_id(id(record)) else { + continue; + }; + match at.get(&key) { + Some(&index) => server[index] = record.clone(), + None => { + at.insert(key, server.len()); + server.push(record.clone()); + } + } + } + let base: HashMap = side + .base + .iter() + .filter_map(|record| record_id(id(record)).map(|key| (key, record))) + .collect(); + for record in side.local { + let Some(key) = record_id(id(record)) else { + continue; + }; + match (base.get(&key), at.get(&key)) { + (Some(held), Some(&index)) => server[index] = fields(held, record, &server[index]), + (None, Some(_)) => {} + (_, None) => { + at.insert(key, server.len()); + server.push(record.clone()); + } + } + } + server + .into_iter() + .filter(|record| record_id(id(record)).is_none_or(|key| !deleted.contains(&key))) + .collect() +} diff --git a/core/crates/storage/src/cloudkit/mod.rs b/core/crates/storage/src/cloudkit/mod.rs new file mode 100644 index 0000000000..ec8ab6e841 --- /dev/null +++ b/core/crates/storage/src/cloudkit/mod.rs @@ -0,0 +1,360 @@ +//! The connection library as the Apple apps sync it through CloudKit: a `ConnectionGroup` record per group, and on +//! each `Connection` record the fields that place it. The app moves the records; this maps them to and from a +//! [`ConnectionList`] and merges a pull with the edits this device has not pushed yet. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::{ConnectionGroup, ConnectionList, SavedConnection}; + +mod merge; +mod order; +mod values; + +#[cfg(test)] +mod tests; + +pub use merge::merged; + +use order::{Sibling, reads_before, sort_orders}; +use values::{ + color, color_value, favorite, favorite_value, group_name, icon, icon_value, id_value, name_order, record_id, + sort_key, +}; + +/// A `ConnectionGroup` record's fields. `None` is a field the record lacks. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudGroup { + pub group_id: String, + pub name: Option, + pub color: Option, + pub icon_name: Option, + pub parent_id: Option, + pub sort_order: Option, +} + +/// The fields of a `Connection` record that place it in the library. The name only breaks a tie in the order: +/// [`records`] writes the one the record has, or the library's for a new record. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudConnection { + pub connection_id: String, + pub name: Option, + pub group_id: Option, + pub sort_order: Option, + pub is_favorite: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudRecords { + pub groups: Vec, + pub connections: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudIds { + pub groups: Vec, + pub connections: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CloudPull { + /// Every library record this device held before the pull, as the server last gave it. + pub base: CloudRecords, + pub changed: CloudRecords, + pub deleted: CloudIds, + /// Deleted on this device and not pushed yet. + pub deleted_here: CloudIds, + /// The favourites' order to keep, when the library does not hold it yet. + pub favorites_order: Option>, +} + +/// The library `records` read as, for the connections `list` holds: the groups they make, where each connection +/// sits, and the favourites, in `favorites_order` and then by name. A connection with no record keeps its place +/// after the ones that have one. A record the core cannot hold, such as a group with no name, is left out. +pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[Uuid]) -> ConnectionList { + let groups = read_groups(&records.groups); + let known: HashSet = groups.iter().map(|group| group.id).collect(); + let recorded = by_id(&records.connections, |record| &record.connection_id); + + let mut places: HashMap, Vec<(Option<(i64, String)>, u32, Uuid)>> = HashMap::new(); + let mut favorites: HashSet = HashSet::new(); + for saved in list.connections.iter() { + let record = recorded.get(&saved.id); + let group = match record { + Some(record) => record.group_id.as_deref().and_then(record_id), + None => saved.group_id, + } + .filter(|id| known.contains(id)); + let key = record.map(|record| { + let name = record.name.clone().unwrap_or_else(|| saved.name.clone()); + (sort_key(record.sort_order), name) + }); + places.entry(group).or_default().push((key, saved.order, saved.id)); + let is_favorite = record.map_or_else(|| list.is_favorite(saved.id), |record| favorite(record.is_favorite)); + if is_favorite { + favorites.insert(saved.id); + } + } + + let mut placed: HashMap, u32)> = HashMap::new(); + for (group, mut members) in places { + members.sort_by(|left, right| match (&left.0, &right.0) { + (Some(l), Some(r)) => reads_before((l.0, &l.1, left.2), (r.0, &r.1, right.2)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => (left.1, left.2).cmp(&(right.1, right.2)), + }); + for ((_, _, id), rank) in members.into_iter().zip(0_u32..) { + placed.insert(id, (group, rank)); + } + } + let connections: Vec = list + .connections + .iter() + .map(|saved| { + let (group_id, order) = placed.get(&saved.id).copied().unwrap_or((None, saved.order)); + SavedConnection { + group_id, + order, + ..saved.clone() + } + }) + .collect(); + + let favorites = favorites_in_order(&connections, &favorites, favorites_order); + ConnectionList { + connections: Arc::from(connections), + groups: Arc::from(groups), + tags: list.tags.clone(), + favorites: Arc::from(favorites), + } +} + +/// Every record the library writes, keeping each value `base` holds wherever it reads as the library's, so a +/// library read from `base` writes `base` back, and a value only another build understands survives. +pub fn records(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { + CloudRecords { + groups: group_records(list, base), + connections: connection_records(list, base), + } +} + +/// The records a push sends: the new ones, and those with a field that differs from `base`. +pub fn edits(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { + let all = records(list, base); + let base_groups = by_id(&base.groups, |record| &record.group_id); + let base_connections = by_id(&base.connections, |record| &record.connection_id); + CloudRecords { + groups: all + .groups + .into_iter() + .filter(|record| changed(&base_groups, &record.group_id, record)) + .collect(), + connections: all + .connections + .into_iter() + .filter(|record| changed(&base_connections, &record.connection_id, record)) + .collect(), + } +} + +fn changed(base: &HashMap, id: &str, record: &R) -> bool { + record_id(id) + .and_then(|id| base.get(&id)) + .is_none_or(|held| *held != record) +} + +fn by_id<'a, R>(records: &'a [R], id: impl Fn(&R) -> &String) -> HashMap { + let mut found = HashMap::with_capacity(records.len()); + for record in records { + if let Some(parsed) = record_id(id(record)) { + found.entry(parsed).or_insert(record); + } + } + found +} + +// The groups as macOS reads them: a parent the records lack, or a cycle, roots the group, and siblings go by +// `sortOrder`, then name. +fn read_groups(records: &[CloudGroup]) -> Vec { + let mut claimed = HashSet::with_capacity(records.len()); + let mut seen = HashSet::with_capacity(records.len()); + let mut read: Vec<(ConnectionGroup, i64)> = Vec::with_capacity(records.len()); + for record in records { + let Some(id) = record_id(&record.group_id).filter(|id| claimed.insert(*id)) else { + continue; + }; + let Some(name) = group_name(record.name.as_deref()) else { + continue; + }; + seen.insert(id); + let group = ConnectionGroup { + id, + name, + parent_id: record.parent_id.as_deref().and_then(record_id), + order: 0, + color: color(record.color.as_deref()), + icon: icon(record.icon_name.as_deref()), + }; + read.push((group, sort_key(record.sort_order))); + } + let mut groups: Vec = read.iter().map(|(group, _)| group.clone()).collect(); + for group in &mut groups { + if group + .parent_id + .is_some_and(|parent| parent == group.id || !seen.contains(&parent)) + { + group.parent_id = None; + } + } + let cyclic = crate::connections::cyclic(&groups); + let mut siblings: HashMap, Vec<(i64, usize)>> = HashMap::new(); + for (index, group) in groups.iter_mut().enumerate() { + if cyclic.contains(&group.id) { + group.parent_id = None; + } + siblings + .entry(group.parent_id) + .or_default() + .push((read[index].1, index)); + } + for mut members in siblings.into_values() { + members.sort_by(|left, right| { + let (l, r) = (&groups[left.1], &groups[right.1]); + reads_before((left.0, l.name.as_str(), l.id), (right.0, r.name.as_str(), r.id)) + }); + for ((_, index), rank) in members.into_iter().zip(0_u32..) { + groups[index].order = rank; + } + } + groups +} + +fn group_records(list: &ConnectionList, base: &CloudRecords) -> Vec { + let held = by_id(&base.groups, |record| &record.group_id); + let as_read: HashMap = read_groups(&base.groups) + .into_iter() + .map(|group| (group.id, group)) + .collect(); + let tree = list.group_tree(); + let mut parents: Vec> = vec![None]; + parents.extend(tree.walk().iter().map(|group| Some(group.id))); + + let mut written = Vec::with_capacity(list.groups.len()); + for parent in parents { + let children = tree.children(parent); + let siblings: Vec> = children + .iter() + .map(|group| Sibling { + id: group.id, + name: group.name.as_str(), + base: held + .get(&group.id) + .filter(|_| as_read.get(&group.id).is_some_and(|read| read.parent_id == parent)) + .map(|record| record.sort_order), + }) + .collect(); + for (group, sort_order) in children.into_iter().zip(sort_orders(&siblings)) { + let record = held.get(&group.id).copied(); + let read = as_read.get(&group.id); + written.push(CloudGroup { + group_id: record.map_or_else(|| id_value(group.id), |record| record.group_id.clone()), + name: match record { + Some(record) if group_name(record.name.as_deref()).as_ref() == Some(&group.name) => { + record.name.clone() + } + _ => Some(group.name.as_str().to_owned()), + }, + color: match record { + Some(record) if color(record.color.as_deref()) == group.color => record.color.clone(), + _ => Some(color_value(group.color)), + }, + icon_name: match record { + Some(record) if icon(record.icon_name.as_deref()) == group.icon => record.icon_name.clone(), + _ => icon_value(group.icon.as_ref()), + }, + parent_id: match (record, read) { + (Some(record), Some(read)) if read.parent_id == group.parent_id => record.parent_id.clone(), + _ => group.parent_id.map(id_value), + }, + sort_order, + }); + } + } + written +} + +fn connection_records(list: &ConnectionList, base: &CloudRecords) -> Vec { + let held = by_id(&base.connections, |record| &record.connection_id); + let known: HashSet = read_groups(&base.groups).iter().map(|group| group.id).collect(); + let group_as_read = |record: &CloudConnection| { + record + .group_id + .as_deref() + .and_then(record_id) + .filter(|id| known.contains(id)) + }; + let mut groups: Vec> = vec![None]; + groups.extend(list.group_tree().walk().iter().map(|group| Some(group.id))); + + let mut written = Vec::with_capacity(list.connections.len()); + for group in groups { + let members = list.connections_in(group); + let names: Vec = members + .iter() + .map(|saved| { + held.get(&saved.id) + .and_then(|record| record.name.clone()) + .unwrap_or_else(|| saved.name.clone()) + }) + .collect(); + let siblings: Vec> = members + .iter() + .zip(&names) + .map(|(saved, name)| Sibling { + id: saved.id, + name, + base: held + .get(&saved.id) + .filter(|record| group_as_read(record) == group) + .map(|record| record.sort_order), + }) + .collect(); + for (saved, sort_order) in members.into_iter().zip(sort_orders(&siblings)) { + let record = held.get(&saved.id).copied(); + let is_favorite = list.is_favorite(saved.id); + written.push(CloudConnection { + connection_id: record.map_or_else(|| id_value(saved.id), |record| record.connection_id.clone()), + name: record.map_or_else(|| Some(saved.name.clone()), |record| record.name.clone()), + group_id: match record { + Some(record) if group_as_read(record) == saved.group_id => record.group_id.clone(), + _ => saved.group_id.map(id_value), + }, + sort_order, + is_favorite: match record { + Some(record) if favorite(record.is_favorite) == is_favorite => record.is_favorite, + _ => Some(favorite_value(is_favorite)), + }, + }); + } + } + written +} + +fn favorites_in_order(connections: &[SavedConnection], favorites: &HashSet, order: &[Uuid]) -> Vec { + let mut listed: HashSet = HashSet::with_capacity(favorites.len()); + let mut ordered: Vec = order + .iter() + .copied() + .filter(|id| favorites.contains(id) && listed.insert(*id)) + .collect(); + let mut rest: Vec<&SavedConnection> = connections + .iter() + .filter(|saved| favorites.contains(&saved.id) && !listed.contains(&saved.id)) + .collect(); + rest.sort_by(|left, right| name_order((&left.name, left.id), (&right.name, right.id))); + ordered.extend(rest.into_iter().map(|saved| saved.id)); + ordered +} diff --git a/core/crates/storage/src/cloudkit/order.rs b/core/crates/storage/src/cloudkit/order.rs new file mode 100644 index 0000000000..02e65b147b --- /dev/null +++ b/core/crates/storage/src/cloudkit/order.rs @@ -0,0 +1,161 @@ +use std::cmp::Ordering; + +use uuid::Uuid; + +use super::values::{name_order, sort_key}; + +pub(super) struct Sibling<'a> { + pub id: Uuid, + pub name: &'a str, + // The base record's raw `sortOrder`, when that record already places the item in this list. + pub base: Option>, +} + +// Each sibling's `sortOrder`, given in the list's order. Values the server holds stay while they still read in +// this order, so a list nobody reordered writes nothing; otherwise the list is numbered from 0, as macOS numbers one. +pub(super) fn sort_orders(siblings: &[Sibling<'_>]) -> Vec> { + keeping_base(siblings).unwrap_or_else(|| { + siblings + .iter() + .zip(0_i64..) + .map(|(sibling, index)| match sibling.base { + Some(raw) if sort_key(raw) == index => raw, + _ => Some(index), + }) + .collect() + }) +} + +pub(super) fn reads_before(left: (i64, &str, Uuid), right: (i64, &str, Uuid)) -> Ordering { + left.0 + .cmp(&right.0) + .then_with(|| name_order((left.1, left.2), (right.1, right.2))) +} + +fn keeping_base(siblings: &[Sibling<'_>]) -> Option>> { + let anchors: Vec<(usize, i64)> = siblings + .iter() + .enumerate() + .filter_map(|(index, sibling)| sibling.base.map(|raw| (index, sort_key(raw)))) + .collect(); + let in_order = anchors.windows(2).all(|pair| { + let (left, right) = (&siblings[pair[0].0], &siblings[pair[1].0]); + reads_before((pair[0].1, left.name, left.id), (pair[1].1, right.name, right.id)).is_lt() + }); + if !in_order { + return None; + } + let mut orders: Vec> = siblings.iter().map(|sibling| sibling.base.flatten()).collect(); + let mut start = 0; + let mut previous: Option = None; + for anchor in anchors.iter().map(Some).chain([None]) { + let end = anchor.map_or(siblings.len(), |(index, _)| *index); + let next = anchor.map(|(_, key)| *key); + fill(&mut orders[start..end], previous, next)?; + if let Some((index, key)) = anchor { + start = index + 1; + previous = Some(*key); + } + } + Some(orders) +} + +// Numbers a run of new siblings strictly between the keys around it, so each reads where it stands. +fn fill(run: &mut [Option], previous: Option, next: Option) -> Option<()> { + let count = i64::try_from(run.len()).ok()?; + if count == 0 { + return Some(()); + } + let first = match (previous, next) { + (Some(previous), Some(next)) if next.checked_sub(previous)?.checked_sub(1)? >= count => previous + 1, + (Some(_), Some(_)) => return None, + (Some(previous), None) => previous.checked_add(1)?, + (None, Some(next)) => next.checked_sub(count)?, + (None, None) => 0, + }; + for (slot, offset) in run.iter_mut().zip(0_i64..) { + *slot = Some(first.checked_add(offset)?); + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(count: usize) -> Vec { + let mut ids: Vec = (0..count).map(|_| Uuid::new_v4()).collect(); + ids.sort(); + ids + } + + fn siblings<'a>(ids: &[Uuid], names: &[&'a str], bases: &[Option>]) -> Vec> { + ids.iter() + .zip(names) + .zip(bases) + .map(|((id, name), base)| Sibling { + id: *id, + name, + base: *base, + }) + .collect() + } + + #[test] + fn a_list_in_the_order_its_records_read_keeps_every_value() { + let ids = ids(3); + let list = siblings(&ids, &["a", "b", "c"], &[Some(Some(0)), Some(Some(5)), Some(Some(7))]); + + assert_eq!(sort_orders(&list), [Some(0), Some(5), Some(7)]); + } + + #[test] + fn a_missing_order_and_a_tie_broken_by_name_are_kept() { + let ids = ids(3); + let list = siblings(&ids, &["a", "B", "c"], &[Some(None), Some(Some(0)), Some(Some(1))]); + + assert_eq!(sort_orders(&list), [None, Some(0), Some(1)]); + } + + #[test] + fn a_new_sibling_takes_a_free_value_between_its_neighbours_or_after_the_last() { + let ids = ids(4); + let between = siblings(&ids, &["a", "b", "c", "d"], &[Some(Some(0)), None, Some(Some(5)), None]); + + assert_eq!(sort_orders(&between), [Some(0), Some(1), Some(5), Some(6)]); + + let first = siblings(&ids[..2], &["a", "b"], &[None, Some(Some(0))]); + + assert_eq!(sort_orders(&first), [Some(-1), Some(0)]); + } + + #[test] + fn a_reordered_list_is_numbered_from_zero_writing_only_what_moved() { + let ids = ids(3); + let list = siblings(&ids, &["c", "a", "b"], &[Some(Some(2)), Some(Some(0)), Some(Some(1))]); + + assert_eq!(sort_orders(&list), [Some(0), Some(1), Some(2)]); + + let moved = siblings(&ids, &["a", "c", "b"], &[Some(Some(0)), Some(Some(2)), Some(Some(1))]); + + assert_eq!(sort_orders(&moved), [Some(0), Some(1), Some(2)]); + } + + #[test] + fn a_new_sibling_with_no_room_between_its_neighbours_renumbers_the_list() { + let ids = ids(3); + let list = siblings(&ids, &["a", "b", "c"], &[Some(Some(0)), None, Some(Some(1))]); + + assert_eq!(sort_orders(&list), [Some(0), Some(1), Some(2)]); + } + + #[test] + fn values_at_the_ends_of_the_range_never_overflow() { + let ids = ids(2); + let after = siblings(&ids, &["a", "b"], &[Some(Some(i64::MAX)), None]); + let before = siblings(&ids, &["a", "b"], &[None, Some(Some(i64::MIN))]); + + assert_eq!(sort_orders(&after), [Some(0), Some(1)]); + assert_eq!(sort_orders(&before), [Some(0), Some(1)]); + } +} diff --git a/core/crates/storage/src/cloudkit/tests.rs b/core/crates/storage/src/cloudkit/tests.rs new file mode 100644 index 0000000000..0613668565 --- /dev/null +++ b/core/crates/storage/src/cloudkit/tests.rs @@ -0,0 +1,830 @@ +use std::collections::{BTreeMap, HashSet}; + +use proptest::prelude::*; +use tablepro_core::{AiPolicy, AuthMode, ExternalAccess, SafeModeLevel, TlsConfig}; +use tempfile::TempDir; +use uuid::Uuid; + +use super::*; +use crate::{ + ConnectionColor, ConnectionGroupName, ConnectionIcon, ConnectionStore, GroupIcon, StoragePaths, SymbolName, +}; + +fn upper(id: Uuid) -> String { + id.hyphenated().to_string().to_uppercase() +} + +fn saved(id: Uuid, name: &str) -> SavedConnection { + SavedConnection { + id, + name: name.to_owned(), + driver_id: "postgres".to_owned(), + host: "db.internal".to_owned(), + port: 5432, + database: "app".to_owned(), + username: "postgres".to_owned(), + tls: TlsConfig::verify_full(), + safe_mode: SafeModeLevel::Off, + external_access: ExternalAccess::ReadOnly, + ai_policy: AiPolicy::Ask, + query_timeout_secs: None, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group_id: None, + order: 0, + tags: Vec::new(), + icon: None, + options: Default::default(), + } +} + +fn group(id: Uuid, name: &str, parent: Option, sort_order: i64) -> CloudGroup { + CloudGroup { + group_id: upper(id), + name: Some(name.to_owned()), + color: Some("None".to_owned()), + icon_name: None, + parent_id: parent.map(upper), + sort_order: Some(sort_order), + } +} + +fn connection(id: Uuid, name: &str, group: Option, sort_order: i64, favorite: bool) -> CloudConnection { + CloudConnection { + connection_id: upper(id), + name: Some(name.to_owned()), + group_id: group.map(upper), + sort_order: Some(sort_order), + is_favorite: Some(i64::from(favorite)), + } +} + +fn library_of(records: &CloudRecords) -> ConnectionList { + let connections: Vec = records + .connections + .iter() + .filter_map(|record| { + let id = values::record_id(&record.connection_id)?; + Some(saved(id, record.name.as_deref().unwrap_or("unnamed"))) + }) + .collect(); + let mut seen = HashSet::new(); + let connections: Vec = connections.into_iter().filter(|saved| seen.insert(saved.id)).collect(); + ConnectionList::new(connections, Vec::new()) +} + +fn names_under(list: &ConnectionList, parent: Option) -> Vec { + list.group_tree() + .children(parent) + .iter() + .map(|group| group.name.as_str().to_owned()) + .collect() +} + +fn names_in(list: &ConnectionList, group: Option) -> Vec { + list.connections_in(group) + .iter() + .map(|saved| saved.name.clone()) + .collect() +} + +#[test] +fn macos_records_read_as_a_tree_and_write_back_unchanged() { + let (work, staging, eu, home) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let records = CloudRecords { + groups: vec![ + CloudGroup { + color: Some("Gray".to_owned()), + icon_name: Some("server.rack".to_owned()), + ..group(work, "Work", None, 1) + }, + CloudGroup { + color: Some("Indigo".to_owned()), + icon_name: Some("cylinder.split.1x2".to_owned()), + ..group(home, "Home\tLab ", None, 0) + }, + group(staging, "Staging", Some(work), 0), + CloudGroup { + icon_name: Some("Not A Symbol".to_owned()), + sort_order: None, + ..group(eu, "EU", Some(staging), 0) + }, + ], + connections: vec![ + connection(Uuid::new_v4(), "b", Some(work), 0, true), + connection(Uuid::new_v4(), "a", Some(work), 0, false), + CloudConnection { + is_favorite: Some(2), + ..connection(Uuid::new_v4(), "top", None, 3, false) + }, + ], + }; + let list = layout(&library_of(&records), &records, &[]); + + assert_eq!(names_under(&list, None), ["Home Lab", "Work"]); + assert_eq!(names_under(&list, Some(work)), ["Staging"]); + assert_eq!(names_under(&list, Some(staging)), ["EU"]); + let tree = list.group_tree(); + let [work_group, home_group, eu_group] = [work, home, eu].map(|id| tree.get(id).expect("the group")); + assert_eq!(work_group.color, Some(ConnectionColor::Slate)); + assert_eq!(work_group.icon, Some(GroupIcon::Named(ConnectionIcon::Server))); + assert_eq!(home_group.color, None); + assert_eq!( + home_group.icon, + Some(GroupIcon::Symbol( + SymbolName::new("cylinder.split.1x2").expect("a symbol") + )) + ); + assert_eq!(eu_group.icon, None); + assert_eq!(names_in(&list, Some(work)), ["a", "b"]); + assert_eq!(list.favorites.len(), 2); + + assert_eq!(edits(&list, &records), CloudRecords::default()); + let mut written = super::records(&list, &records); + let mut expected = records.clone(); + for set in [&mut written, &mut expected] { + set.groups.sort_by(|l, r| l.group_id.cmp(&r.group_id)); + set.connections.sort_by(|l, r| l.connection_id.cmp(&r.connection_id)); + } + assert_eq!(written, expected); +} + +#[test] +fn a_parent_the_records_lack_or_a_cycle_roots_the_group_and_its_record_keeps_the_parent() { + let (lone, first, second, gone) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let records = CloudRecords { + groups: vec![ + group(lone, "Lone", Some(gone), 0), + group(first, "First", Some(second), 1), + group(second, "Second", Some(first), 2), + CloudGroup { + name: Some(" \t ".to_owned()), + ..group(Uuid::new_v4(), "", None, 3) + }, + ], + connections: vec![connection(Uuid::new_v4(), "stray", Some(gone), 0, false)], + }; + let list = layout(&library_of(&records), &records, &[]); + + assert_eq!(names_under(&list, None), ["Lone", "First", "Second"]); + assert_eq!(names_in(&list, None), ["stray"]); + assert_eq!(edits(&list, &records), CloudRecords::default()); +} + +#[test] +fn a_new_library_writes_records_as_macos_does_and_reads_them_back() { + let (work, staging) = (Uuid::new_v4(), Uuid::new_v4()); + let (a, b, c) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let groups = vec![ + ConnectionGroup { + id: work, + order: 0, + color: Some(ConnectionColor::Slate), + icon: Some(GroupIcon::Named(ConnectionIcon::Lab)), + ..ConnectionGroup::new(ConnectionGroupName::new("Work").expect("a name"), None) + }, + ConnectionGroup { + id: staging, + order: 0, + color: Some(ConnectionColor::Teal), + ..ConnectionGroup::new(ConnectionGroupName::new("Staging").expect("a name"), Some(work)) + }, + ]; + let connections = vec![ + SavedConnection { + group_id: Some(staging), + order: 0, + ..saved(a, "a") + }, + SavedConnection { + group_id: Some(staging), + order: 1, + ..saved(b, "b") + }, + saved(c, "c"), + ]; + let list = ConnectionList::new(connections, Vec::new()) + .with_groups(groups) + .with_favorites(vec![b, c]); + + let written = records(&list, &CloudRecords::default()); + + assert_eq!( + written.groups, + [ + CloudGroup { + color: Some("Gray".to_owned()), + icon_name: Some("flask".to_owned()), + ..group(work, "Work", None, 0) + }, + CloudGroup { + color: Some("Teal".to_owned()), + ..group(staging, "Staging", Some(work), 0) + }, + ] + ); + assert_eq!( + written.connections, + [ + connection(c, "c", None, 0, true), + connection(a, "a", Some(staging), 0, false), + connection(b, "b", Some(staging), 1, true), + ] + ); + let read = layout(&list, &written, &[b, c]); + assert_eq!(read.groups.to_vec(), list.groups.to_vec()); + assert_eq!(read.connections.to_vec(), list.connections.to_vec()); + assert_eq!(read.favorites.to_vec(), [b, c]); +} + +#[test] +fn an_edit_writes_only_the_records_and_fields_it_changed() { + let (work, home) = (Uuid::new_v4(), Uuid::new_v4()); + let (a, b, c) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let base = CloudRecords { + groups: vec![group(work, "Work", None, 0), group(home, "Home", None, 1)], + connections: vec![ + connection(a, "a", Some(work), 0, true), + connection(b, "b", Some(work), 1, false), + connection(c, "c", Some(work), 2, false), + ], + }; + let list = layout(&library_of(&base), &base, &[]); + let mut connections = list.connections.to_vec(); + for saved in &mut connections { + if saved.id == b { + saved.group_id = Some(home); + saved.order = 0; + } + } + let mut groups = list.groups.to_vec(); + groups[0].name = ConnectionGroupName::new("Office").expect("a name"); + let edited = ConnectionList::new(connections, Vec::new()) + .with_groups(groups) + .with_favorites(Vec::new()); + + let sent = edits(&edited, &base); + + assert_eq!( + sent.groups, + [CloudGroup { + name: Some("Office".to_owned()), + ..base.groups[0].clone() + }] + ); + assert_eq!( + sent.connections, + [ + CloudConnection { + is_favorite: Some(0), + ..base.connections[0].clone() + }, + connection(b, "b", Some(home), 0, false), + ] + ); +} + +#[test] +fn favourites_keep_this_devices_order_then_take_new_ones_by_name() { + let ids: Vec = (0..4).map(|_| Uuid::new_v4()).collect(); + let records = CloudRecords { + groups: Vec::new(), + connections: ["delta", "alpha", "Charlie", "bravo"] + .iter() + .zip(&ids) + .zip(0..) + .map(|((name, id), order)| connection(*id, name, None, order, true)) + .collect(), + }; + + let list = layout(&library_of(&records), &records, &[ids[2], Uuid::new_v4(), ids[0]]); + + assert_eq!(list.favorites.to_vec(), [ids[2], ids[0], ids[1], ids[3]]); +} + +fn pull_over(base: &CloudRecords, changed: CloudRecords) -> CloudPull { + CloudPull { + base: base.clone(), + changed, + ..CloudPull::default() + } +} + +#[test] +fn fields_two_devices_changed_apart_both_survive_and_this_devices_unpushed_edit_wins_its_field() { + let work = Uuid::new_v4(); + let base = CloudRecords { + groups: vec![group(work, "Work", None, 0)], + connections: Vec::new(), + }; + let mut groups = layout(&library_of(&base), &base, &[]).groups.to_vec(); + groups[0].name = ConnectionGroupName::new("Office").expect("a name"); + groups[0].icon = Some(GroupIcon::Named(ConnectionIcon::Star)); + let local = ConnectionList::new(Vec::new(), Vec::new()).with_groups(groups); + let server = CloudGroup { + color: Some("Red".to_owned()), + icon_name: Some("bolt".to_owned()), + ..base.groups[0].clone() + }; + + let merged = merged( + &local, + &pull_over( + &base, + CloudRecords { + groups: vec![server], + connections: Vec::new(), + }, + ), + ); + + assert_eq!( + merged.groups, + [CloudGroup { + name: Some("Office".to_owned()), + color: Some("Red".to_owned()), + icon_name: Some("star".to_owned()), + ..base.groups[0].clone() + }] + ); +} + +#[test] +fn a_deletion_from_either_side_wins_and_a_record_with_no_base_takes_the_servers_copy() { + let (kept, gone, dropped, fresh) = (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4()); + let base = CloudRecords { + groups: vec![ + group(kept, "Kept", None, 0), + group(gone, "Gone", None, 1), + group(dropped, "Dropped", None, 2), + ], + connections: Vec::new(), + }; + let mut groups = layout(&library_of(&base), &base, &[]).groups.to_vec(); + for group in &mut groups { + group.name = ConnectionGroupName::new(&format!("{} here", group.name)).expect("a name"); + } + groups.push(ConnectionGroup { + id: fresh, + ..ConnectionGroup::new(ConnectionGroupName::new("Fresh here").expect("a name"), None) + }); + let local = ConnectionList::new(Vec::new(), Vec::new()).with_groups(groups); + let pull = CloudPull { + deleted: CloudIds { + groups: vec![gone], + connections: Vec::new(), + }, + deleted_here: CloudIds { + groups: vec![dropped], + connections: Vec::new(), + }, + ..pull_over( + &base, + CloudRecords { + groups: vec![group(fresh, "Fresh there", None, 5)], + connections: Vec::new(), + }, + ) + }; + + let names: Vec> = merged(&local, &pull).groups.into_iter().map(|g| g.name).collect(); + + assert_eq!(names, [Some("Kept here".to_owned()), Some("Fresh there".to_owned())]); +} + +#[test] +fn a_move_here_that_closes_a_cycle_with_another_devices_move_yields_to_it() { + let (a, b) = (Uuid::new_v4(), Uuid::new_v4()); + let base = CloudRecords { + groups: vec![group(a, "A", None, 0), group(b, "B", None, 1)], + connections: Vec::new(), + }; + let mut groups = layout(&library_of(&base), &base, &[]).groups.to_vec(); + for group in &mut groups { + if group.id == a { + group.parent_id = Some(b); + } + } + let local = ConnectionList::new(Vec::new(), Vec::new()).with_groups(groups); + let server = CloudRecords { + groups: vec![group(a, "A", None, 0), group(b, "B", Some(a), 1)], + connections: Vec::new(), + }; + + let after = layout(&local, &merged(&local, &pull_over(&base, server.clone())), &[]); + + assert_eq!(names_under(&after, None), ["A"]); + assert_eq!(names_under(&after, Some(a)), ["B"]); + assert_eq!(edits(&after, &server), CloudRecords::default()); +} + +#[test] +fn a_connection_in_a_group_deleted_elsewhere_goes_to_the_top_and_its_record_stays() { + let (work, a) = (Uuid::new_v4(), Uuid::new_v4()); + let base = CloudRecords { + groups: vec![group(work, "Work", None, 0)], + connections: vec![connection(a, "a", Some(work), 0, false)], + }; + let list = layout(&library_of(&base), &base, &[]); + let pull = CloudPull { + deleted: CloudIds { + groups: vec![work], + connections: Vec::new(), + }, + ..pull_over(&base, CloudRecords::default()) + }; + + let records_after = merged(&list, &pull); + let after = layout(&list, &records_after, &[]); + + assert!(after.groups.is_empty()); + assert_eq!(names_in(&after, None), ["a"]); + let server = CloudRecords { + groups: Vec::new(), + connections: base.connections.clone(), + }; + assert_eq!(edits(&after, &server), CloudRecords::default()); +} + +fn raw_id(ids: &[Uuid]) -> impl Strategy> + use<> { + let ids = ids.to_vec(); + prop_oneof![ + Just(None), + (0..ids.len()).prop_map(move |at| Some(upper(ids[at]))), + Just(Some("not an id".to_owned())), + Just(Some(upper(Uuid::nil()))), + ] +} + +fn records_strategy() -> impl Strategy { + let ids: Vec = (0..6).map(|_| Uuid::new_v4()).collect(); + let names = prop::sample::select(vec!["Work", "work", "Prod 10", "Prod 9", "", " \t", "A\u{7}b", "Ünï"]); + let colors = prop::sample::select(vec![ + None, + Some("None"), + Some("Red"), + Some("Gray"), + Some("Teal"), + Some("red"), + ]); + let icons = prop::sample::select(vec![ + None, + Some(""), + Some("folder"), + Some("bolt.fill"), + Some("Bad Name"), + ]); + let orders = prop_oneof![Just(None), (-2_i64..4).prop_map(Some), Just(Some(i64::MAX))]; + let group = ( + prop::sample::select(ids.clone()), + names.clone(), + colors, + icons, + raw_id(&ids), + orders.clone(), + ) + .prop_map(|(id, name, color, icon, parent_id, sort_order)| CloudGroup { + group_id: upper(id), + name: Some(name.to_owned()), + color: color.map(str::to_owned), + icon_name: icon.map(str::to_owned), + parent_id, + sort_order, + }); + let connection_ids: Vec = (0..6).map(|_| Uuid::new_v4()).collect(); + let connection = ( + prop::sample::select(connection_ids), + names, + raw_id(&ids), + orders, + prop_oneof![Just(None), (0_i64..3).prop_map(Some)], + ) + .prop_map(|(id, name, group_id, sort_order, is_favorite)| CloudConnection { + connection_id: upper(id), + name: Some(name.to_owned()), + group_id, + sort_order, + is_favorite, + }); + ( + prop::collection::vec(group, 0..8), + prop::collection::vec(connection, 0..8), + ) + .prop_map(|(groups, connections)| CloudRecords { groups, connections }) +} + +proptest! { + #[test] + fn any_records_read_and_written_back_send_no_edit(records in records_strategy()) { + let list = layout(&library_of(&records), &records, &[]); + + prop_assert_eq!(edits(&list, &records), CloudRecords::default()); + let again = layout(&list, &super::records(&list, &records), &list.favorites); + prop_assert_eq!(shape(&again), shape(&list)); + } +} + +// Two devices editing one library apart, syncing through a server that rebases each push as CloudKit does. +struct Device { + store: ConnectionStore, + _root: TempDir, + cache: CloudRecords, + deleted_here: CloudIds, +} + +#[derive(Default)] +struct Server { + groups: BTreeMap, + connections: BTreeMap, +} + +fn key(id: &str) -> String { + id.to_uppercase() +} + +impl Device { + fn new(connections: &[SavedConnection]) -> Self { + let root = TempDir::new().expect("tempdir"); + let store = ConnectionStore::new(&StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro")); + for saved in connections { + store.upsert_blocking(saved.clone()).expect("the connection"); + } + Self { + store, + _root: root, + cache: CloudRecords::default(), + deleted_here: CloudIds::default(), + } + } + + // A push saves each edit onto the server's copy, then takes the saved copies in as a pull would: a copy rebased + // onto another device's change holds fields this library has not read yet. + fn push(&mut self, server: &mut Server) { + let sent = self.store.cloud_edits_blocking(&self.cache).expect("the edits"); + let mut saved = CloudRecords::default(); + for record in sent.groups { + let base = self + .cache + .groups + .iter() + .find(|held| key(&held.group_id) == key(&record.group_id)); + let stored = server + .groups + .entry(key(&record.group_id)) + .or_insert_with(|| record.clone()); + *stored = base.map_or_else(|| record.clone(), |base| merge_group(base, &record, stored)); + saved.groups.push(stored.clone()); + } + for record in sent.connections { + let base = self + .cache + .connections + .iter() + .find(|held| key(&held.connection_id) == key(&record.connection_id)); + let stored = server + .connections + .entry(key(&record.connection_id)) + .or_insert_with(|| record.clone()); + *stored = base.map_or_else(|| record.clone(), |base| merge_connection(base, &record, stored)); + saved.connections.push(stored.clone()); + } + let deleted = std::mem::take(&mut self.deleted_here); + for id in &deleted.groups { + server.groups.remove(&upper(*id)); + } + for id in &deleted.connections { + server.connections.remove(&upper(*id)); + } + let pull = CloudPull { + base: self.cache.clone(), + changed: saved.clone(), + deleted: deleted.clone(), + ..CloudPull::default() + }; + self.store.apply_cloud_pull_blocking(&pull).expect("the saved records"); + let gone: HashSet = deleted + .groups + .iter() + .chain(&deleted.connections) + .map(|id| upper(*id)) + .collect(); + self.cache.groups.retain(|held| !gone.contains(&key(&held.group_id))); + self.cache + .connections + .retain(|held| !gone.contains(&key(&held.connection_id))); + for record in saved.groups { + self.cache + .groups + .retain(|held| key(&held.group_id) != key(&record.group_id)); + self.cache.groups.push(record); + } + for record in saved.connections { + self.cache + .connections + .retain(|held| key(&held.connection_id) != key(&record.connection_id)); + self.cache.connections.push(record); + } + } + + fn pull(&mut self, server: &Server) { + let current = server.snapshot(); + let held = + |records: &CloudRecords| -> HashSet { records.groups.iter().map(|g| key(&g.group_id)).collect() }; + let deleted_groups: Vec = held(&self.cache) + .difference(&held(¤t)) + .filter_map(|id| values::record_id(id)) + .collect(); + let pull = CloudPull { + base: self.cache.clone(), + changed: current.clone(), + deleted: CloudIds { + groups: deleted_groups, + connections: Vec::new(), + }, + deleted_here: self.deleted_here.clone(), + favorites_order: None, + }; + self.store.apply_cloud_pull_blocking(&pull).expect("the pull"); + self.cache = current; + } + + fn list(&self) -> ConnectionList { + self.store.list_blocking().expect("the list") + } +} + +impl Server { + fn snapshot(&self) -> CloudRecords { + CloudRecords { + groups: self.groups.values().cloned().collect(), + connections: self.connections.values().cloned().collect(), + } + } +} + +fn pick(base: &T, edit: &T, server: &T) -> T { + if edit == base { server.clone() } else { edit.clone() } +} + +fn merge_group(base: &CloudGroup, edit: &CloudGroup, server: &CloudGroup) -> CloudGroup { + CloudGroup { + group_id: server.group_id.clone(), + name: pick(&base.name, &edit.name, &server.name), + color: pick(&base.color, &edit.color, &server.color), + icon_name: pick(&base.icon_name, &edit.icon_name, &server.icon_name), + parent_id: pick(&base.parent_id, &edit.parent_id, &server.parent_id), + sort_order: pick(&base.sort_order, &edit.sort_order, &server.sort_order), + } +} + +fn merge_connection(base: &CloudConnection, edit: &CloudConnection, server: &CloudConnection) -> CloudConnection { + CloudConnection { + connection_id: server.connection_id.clone(), + name: server.name.clone(), + group_id: pick(&base.group_id, &edit.group_id, &server.group_id), + sort_order: pick(&base.sort_order, &edit.sort_order, &server.sort_order), + is_favorite: pick(&base.is_favorite, &edit.is_favorite, &server.is_favorite), + } +} + +// xorshift, so a failing seed replays. +struct Dice(u64); + +impl Dice { + fn roll(&mut self, sides: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + usize::try_from(self.0 % u64::try_from(sides.max(1)).expect("a count")).expect("an index") + } + + fn pick(&mut self, items: &[T]) -> Option { + (!items.is_empty()).then(|| items[self.roll(items.len())]) + } +} + +const GROUP_NAMES: [&str; 6] = ["Work", "Home", "Staging", "EU", "work", "Lab"]; + +fn group_name(dice: &mut Dice) -> ConnectionGroupName { + ConnectionGroupName::new(GROUP_NAMES[dice.roll(GROUP_NAMES.len())]).expect("a name") +} + +// Refused moves and names are part of the game: each device only does what its library allows. +fn play(device: &mut Device, dice: &mut Dice) { + let list = device.list(); + let groups: Vec = list.groups.iter().map(|group| group.id).collect(); + let connections: Vec = list.connections.iter().map(|saved| saved.id).collect(); + let store = &device.store; + match dice.roll(9) { + 0 => { + let parent = if dice.roll(2) == 0 { None } else { dice.pick(&groups) }; + let _ = store.create_group_blocking(ConnectionGroup::new(group_name(dice), parent)); + } + 1 => { + if let Some(id) = dice.pick(&groups) { + let name = group_name(dice); + let _ = store.rename_group_blocking(id, name); + } + } + 2 => { + if let Some(id) = dice.pick(&groups) { + let color = dice.pick(&ConnectionColor::ALL); + let _ = store.set_group_color_blocking(id, color); + } + } + 3 => { + if let Some(id) = dice.pick(&groups) { + let icon = dice.pick(&ConnectionIcon::ALL).map(GroupIcon::Named); + let _ = store.set_group_icon_blocking(id, icon); + } + } + 4 => { + if let Some(id) = dice.pick(&groups) { + let parent = if dice.roll(2) == 0 { None } else { dice.pick(&groups) }; + let _ = store.move_groups_blocking(&[id], parent, dice.pick(&groups)); + } + } + 5 => { + if let Some(id) = dice.pick(&groups) { + let mut gone = list.group_tree().descendants(id); + gone.push(id); + store.delete_group_blocking(id).expect("the delete"); + device.deleted_here.groups.extend(gone); + } + } + 6 => { + if let Some(id) = dice.pick(&connections) { + let group = if dice.roll(3) == 0 { None } else { dice.pick(&groups) }; + let _ = store.move_connections_blocking(&[id], group, dice.pick(&connections)); + } + } + 7 => { + if let Some(id) = dice.pick(&connections) { + store + .add_favorites_blocking(&[id], dice.pick(&connections)) + .expect("a favourite"); + } + } + _ => { + if let Some(id) = dice.pick(&connections) { + store.remove_favorites_blocking(&[id]).expect("no favourite"); + } + } + } +} + +fn settle(devices: &mut [Device], server: &mut Server) { + for _ in 0..4 { + for device in devices.iter_mut() { + device.push(server); + device.pull(server); + } + } +} + +fn shape(list: &ConnectionList) -> (Vec, Vec<(Uuid, Option, u32)>, Vec) { + let mut groups = list.groups.to_vec(); + groups.sort_by_key(|group| group.id); + let mut connections: Vec<(Uuid, Option, u32)> = list + .connections + .iter() + .map(|saved| (saved.id, saved.group_id, saved.order)) + .collect(); + connections.sort(); + let mut favorites = list.favorites.to_vec(); + favorites.sort(); + (groups, connections, favorites) +} + +#[test] +fn two_devices_editing_apart_converge_and_then_send_nothing() { + let connections: Vec = ["alpha", "bravo", "charlie", "delta", "echo"] + .iter() + .map(|name| saved(Uuid::new_v4(), name)) + .collect(); + for seed in 1..=24_u64 { + let mut dice = Dice(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let mut server = Server::default(); + let mut devices = [Device::new(&connections), Device::new(&connections)]; + settle(&mut devices, &mut server); + for _ in 0..30 { + let at = dice.roll(2); + play(&mut devices[at], &mut dice); + match dice.roll(4) { + 0 => devices[at].push(&mut server), + 1 => devices[at].pull(&server), + _ => {} + } + } + settle(&mut devices, &mut server); + + let [first, second] = &devices; + assert_eq!(shape(&first.list()), shape(&second.list()), "seed {seed}"); + for device in &devices { + let sent = device.store.cloud_edits_blocking(&device.cache).expect("the edits"); + assert_eq!(sent, CloudRecords::default(), "seed {seed}"); + } + } +} diff --git a/core/crates/storage/src/cloudkit/values.rs b/core/crates/storage/src/cloudkit/values.rs new file mode 100644 index 0000000000..92f96a2fb0 --- /dev/null +++ b/core/crates/storage/src/cloudkit/values.rs @@ -0,0 +1,201 @@ +use std::cmp::Ordering; + +use tablepro_core::value::order::natural_text_order; +use unicode_normalization::UnicodeNormalization; +use unicode_normalization::char::is_combining_mark; +use uuid::Uuid; + +use crate::{ConnectionColor, ConnectionGroupName, ConnectionIcon, GroupIcon, SymbolName}; + +// How a field the macOS app wrote reads in the core, and what the core writes for a value of its own. +// A value no reader here understands reads as the default, and the record keeps it until the user +// changes that field (`super::records`). + +// As Swift's `UUID(uuidString:)` reads one: hyphenated, in either case. +pub(super) fn record_id(raw: &str) -> Option { + (raw.len() == 36).then(|| Uuid::try_parse(raw).ok()).flatten() +} + +// `uuidString` is upper case, so an id the core writes reads the same in a Swift log or a string compare. +pub(super) fn id_value(id: Uuid) -> String { + id.hyphenated().to_string().to_uppercase() +} + +pub(super) fn group_name(raw: Option<&str>) -> Option { + raw.and_then(ConnectionGroupName::from_stored) +} + +// The macOS `ConnectionColor` raw values a group record holds. Teal is the core's alone: a macOS build reads it +// as no colour and keeps it until someone picks another. +const COLORS: [(ConnectionColor, &str); 9] = [ + (ConnectionColor::Red, "Red"), + (ConnectionColor::Orange, "Orange"), + (ConnectionColor::Yellow, "Yellow"), + (ConnectionColor::Green, "Green"), + (ConnectionColor::Blue, "Blue"), + (ConnectionColor::Purple, "Purple"), + (ConnectionColor::Pink, "Pink"), + (ConnectionColor::Slate, "Gray"), + (ConnectionColor::Teal, "Teal"), +]; + +const NO_COLOR: &str = "None"; + +pub(super) fn color(raw: Option<&str>) -> Option { + let raw = raw?; + COLORS.iter().find(|(_, name)| *name == raw).map(|(color, _)| *color) +} + +pub(super) fn color_value(color: Option) -> String { + color + .and_then(|color| COLORS.iter().find(|(known, _)| *known == color)) + .map_or(NO_COLOR, |(_, name)| *name) + .to_owned() +} + +// The SF Symbol each named icon is written as. A record naming one of these reads back as the named icon, so the +// value round-trips and a library exported from a Mac carries icons every app draws. +const NAMED_SYMBOLS: [(ConnectionIcon, &str); 15] = [ + (ConnectionIcon::Server, "server.rack"), + (ConnectionIcon::Computer, "desktopcomputer"), + (ConnectionIcon::Disk, "externaldrive"), + (ConnectionIcon::Folder, "folder"), + (ConnectionIcon::Home, "house"), + (ConnectionIcon::Cloud, "cloud"), + (ConnectionIcon::Web, "globe"), + (ConnectionIcon::Terminal, "terminal"), + (ConnectionIcon::Package, "shippingbox"), + (ConnectionIcon::Lab, "flask"), + (ConnectionIcon::Tools, "wrench.and.screwdriver"), + (ConnectionIcon::Star, "star"), + (ConnectionIcon::Warning, "exclamationmark.triangle"), + (ConnectionIcon::Shield, "shield"), + (ConnectionIcon::Lock, "lock"), +]; + +pub(super) fn icon(raw: Option<&str>) -> Option { + let symbol = SymbolName::new(raw?)?; + let named = NAMED_SYMBOLS + .iter() + .find(|(_, name)| *name == symbol.as_str()) + .map(|(icon, _)| *icon); + Some(named.map_or(GroupIcon::Symbol(symbol), GroupIcon::Named)) +} + +pub(super) fn icon_value(icon: Option<&GroupIcon>) -> Option { + match icon? { + GroupIcon::Named(named) => NAMED_SYMBOLS + .iter() + .find(|(known, _)| known == named) + .map(|(_, name)| (*name).to_owned()), + GroupIcon::Symbol(symbol) => Some(symbol.as_str().to_owned()), + } +} + +// Swift reads a missing number as 0. +pub(super) fn sort_key(raw: Option) -> i64 { + raw.unwrap_or(0) +} + +pub(super) fn favorite(raw: Option) -> bool { + raw.is_some_and(|flag| flag != 0) +} + +pub(super) fn favorite_value(favorite: bool) -> i64 { + i64::from(favorite) +} + +// The macOS tie-break between equal sort orders is `localizedStandardCompare`, then the id. Without a locale this +// follows its English order: digit runs by value, then letters without accents or case, then accents, then lower +// case first (apple/scripts/probes/check-library-name-order.sh measures Foundation against the same pairs). +pub(super) fn name_order(left: (&str, Uuid), right: (&str, Uuid)) -> Ordering { + let (l, r) = (left.0, right.0); + natural_text_order(&letters(l), &letters(r)) + .then_with(|| natural_text_order(&accented(l), &accented(r))) + .then_with(|| cases(l).cmp(&cases(r))) + .then_with(|| l.len().cmp(&r.len())) + .then_with(|| l.cmp(r)) + .then(left.1.cmp(&right.1)) +} + +fn accented(text: &str) -> String { + text.nfd().flat_map(char::to_lowercase).collect() +} + +fn letters(text: &str) -> String { + let mut folded = String::with_capacity(text.len()); + for c in text + .nfd() + .filter(|c| !is_combining_mark(*c)) + .flat_map(char::to_lowercase) + { + match c { + 'ß' => folded.push_str("ss"), + 'æ' => folded.push_str("ae"), + 'œ' => folded.push_str("oe"), + 'þ' => folded.push_str("th"), + 'ø' => folded.push('o'), + 'đ' | 'ð' => folded.push('d'), + 'ł' => folded.push('l'), + 'ı' => folded.push('i'), + _ => folded.push(c), + } + } + folded +} + +fn cases(text: &str) -> Vec { + text.chars() + .filter(|c| c.is_alphabetic()) + .map(char::is_uppercase) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // Foundation's verdicts on macOS 27 in English, as the probe prints them. + const FOUNDATION: [(&str, &str); 16] = [ + ("é", "z"), + ("a", "A"), + ("a", "B"), + ("Prod 9", "Prod 10"), + ("e", "é"), + ("é", "f"), + ("work", "Work"), + ("a", "ä"), + ("ss", "ß"), + ("é", "Z"), + ("_a", "a"), + ("a b", "ab"), + ("ü", "v"), + ("Ø", "p"), + ("1", "a"), + ("a1", "a01"), + ]; + + #[test] + fn names_order_as_finder_lists_them() { + let id = Uuid::nil(); + for (first, second) in FOUNDATION { + assert_eq!( + name_order((first, id), (second, id)), + Ordering::Less, + "{first} before {second}" + ); + assert_eq!( + name_order((second, id), (first, id)), + Ordering::Greater, + "{second} after {first}" + ); + } + } + + #[test] + fn the_same_name_falls_back_to_the_id() { + let (low, high) = (Uuid::from_u128(1), Uuid::from_u128(2)); + + assert_eq!(name_order(("Work", low), ("Work", high)), Ordering::Less); + } +} diff --git a/core/crates/storage/src/connections/cloud.rs b/core/crates/storage/src/connections/cloud.rs new file mode 100644 index 0000000000..cb540c8ce4 --- /dev/null +++ b/core/crates/storage/src/connections/cloud.rs @@ -0,0 +1,32 @@ +use std::collections::HashSet; + +use uuid::Uuid; + +use super::ConnectionStore; +use crate::cloudkit::{self, CloudPull, CloudRecords}; +use crate::error::StorageError; + +impl ConnectionStore { + /// The records a push sends for the library, against what the server last gave this device. + pub fn cloud_edits_blocking(&self, base: &CloudRecords) -> Result { + Ok(cloudkit::edits(&self.list_blocking()?, base)) + } + + /// Lays a pull over the library in one write. A connection the pull deleted leaves it; one the library does + /// not hold yet is the app's to add first. + pub fn apply_cloud_pull_blocking(&self, pull: &CloudPull) -> Result<(), StorageError> { + self.edit(|document| { + let deleted: HashSet = pull.deleted.connections.iter().copied().collect(); + document.connections.retain(|saved| !deleted.contains(&saved.id)); + document.favorites.retain(|id| !deleted.contains(id)); + let list = document.list(); + let merged = cloudkit::merged(&list, pull); + let order = pull.favorites_order.as_deref().unwrap_or(&list.favorites); + let laid = cloudkit::layout(&list, &merged, order); + document.groups = laid.groups.to_vec(); + document.connections = laid.connections.to_vec(); + document.favorites = laid.favorites.to_vec(); + Ok(()) + }) + } +} diff --git a/core/crates/storage/src/connections/mod.rs b/core/crates/storage/src/connections/mod.rs index a2c4a4e5af..a20ee5424b 100644 --- a/core/crates/storage/src/connections/mod.rs +++ b/core/crates/storage/src/connections/mod.rs @@ -14,10 +14,13 @@ use crate::document_problem::{DocumentProblem, DocumentProblemKind}; use crate::error::StorageError; use crate::{ConnectionGroup, ConnectionTag, ConnectionTagName, GroupTree}; +mod cloud; mod legacy_groups; mod library; mod placement; +pub(crate) use placement::cyclic; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SavedConnection { pub id: Uuid, diff --git a/core/crates/storage/src/connections/placement.rs b/core/crates/storage/src/connections/placement.rs index ebda32cfc0..943d1841e9 100644 --- a/core/crates/storage/src/connections/placement.rs +++ b/core/crates/storage/src/connections/placement.rs @@ -106,7 +106,7 @@ pub(super) fn unique(ids: &[Uuid]) -> Vec { ids.iter().copied().filter(|id| seen.insert(*id)).collect() } -fn cyclic(groups: &[ConnectionGroup]) -> HashSet { +pub(crate) fn cyclic(groups: &[ConnectionGroup]) -> HashSet { let parents: HashMap> = groups.iter().map(|group| (group.id, group.parent_id)).collect(); let mut cyclic = HashSet::new(); let mut settled = HashSet::new(); diff --git a/core/crates/storage/src/lib.rs b/core/crates/storage/src/lib.rs index 8aeeb25933..34edd9f64c 100644 --- a/core/crates/storage/src/lib.rs +++ b/core/crates/storage/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cloudkit; mod connection_color; mod connection_group; mod connection_group_name; diff --git a/core/docs/decisions/0009-macos-on-the-core.md b/core/docs/decisions/0009-macos-on-the-core.md new file mode 100644 index 0000000000..dcb4dbfb61 --- /dev/null +++ b/core/docs/decisions/0009-macos-on-the-core.md @@ -0,0 +1,99 @@ +# 0009: The macOS app moves to the core in slices, starting with the connection library + +- **Status**: Accepted +- **Date**: 2026-11-04 + +## Context + +The macOS app keeps everything in Swift: its connection library, sync, history, editor and a plugin per engine. The core has the same library ([0006](0006-connection-library.md)), the drivers, sessions, storage and the SQL script reader, and the Windows app already reaches them over UniFFI ([0002](0002-uniffi-bindings.md)). The Mac app has users and data, so it cannot switch at once: each piece has to move with its tests passing and its data intact. + +The library is the first piece both apps model alike, but the Mac syncs it through CloudKit in a shape of its own. A `ConnectionGroup` record per group holds `groupId`, `name`, `color`, `iconName`, `parentId` and `sortOrder`. Each `Connection` record holds `groupId`, `sortOrder` and `isFavorite`, and the favourites' order is a list each Mac keeps in its defaults. The core holds one ordered favourites list and a group tree. Both apps pin the CloudKit container to Production, where a field nobody deployed makes CloudKit refuse the whole record. + +## Decision + +The Mac app moves to the core one slice at a time, reaching it through UniFFI's Swift bindings in an XCFramework of the core's static library, while its UI, the Keychain and the CloudKit transport stay in Swift. The first slice is the connection library, whose CloudKit records the core maps both ways. + +## Slices + +Each slice ships on its own, in this order: + +1. **The connection library and its CloudKit mapping.** + - 1a, this decision's change: the mapping in `tablepro_storage::cloudkit`, `ConnectionLibrary::cloud_edits` and `apply_cloud_pull` in `ffi`, `apple/scripts/build-core.sh`, and the `TableProFFI` package with its tests. + - 1b: the app keeps its groups, each connection's group and place, and the favourites in the core's library. `GroupStorage`, the favourites order in `ConnectionListPreferences` and the placement fields of `DatabaseConnection` read and write through `ConnectionLibrary`, and `SyncCoordinator` turns `CKRecord` fields into `CloudLibraryRecords` and back. +2. **The connections themselves**: every field the `Connection` record carries, with passwords in the Keychain through `HostSecrets`, and the tags. +3. **Query history and saved queries.** +4. **The SQL script reader** (`sql_syntax::script`) for the editor's runs, splitting and formatting. +5. **Engines**, one at a time, when the driver ABI ([0004](0004-driver-plugin-abi.md)) or a linked engine replaces that engine's plugin. + +The iOS app follows once 1b ships, with iOS slices in the same XCFramework. + +## How Swift reaches the core + +- **One UniFFI API.** `uniffi-bindgen-swift` comes from `core/crates/bindgen`, a crate of its own as 0002 says. The bindings are generated from the static library each build makes and are not in git: UniFFI checks at load that the Swift code and the library carry the same API checksums, and the library is built on the Mac anyway, so a committed copy would be one more thing to keep in step. +- **The XCFramework.** `TableProFFIC.xcframework` holds one `macos-arm64_x86_64` slice, a universal static library made with `lipo`, since an XCFramework splits by platform and never by architecture. Its headers and `module.modulemap` make the C module `TableProFFIC`; the generated Swift is the module `TableProFFI` in `apple/Packages/TableProFFI`. iOS adds `ios-arm64` and `ios-arm64-simulator` slices. +- **The static library.** `cargo rustc -p tablepro-ffi --release --no-default-features --crate-type staticlib`: the crate stays a cdylib for Windows, and `--no-default-features` leaves out the `engines` feature, every driver, which no Mac slice uses before slice 5. +- **The floor.** `MACOSX_DEPLOYMENT_TARGET` is the app's deployment target from `apple/project.yml`, macOS 13.0 today, read through `apple/scripts/lib/deployment-target.sh`, and the script fails when any object in the library asks for a newer macOS. The libraries in `apple/Libs/` are built for macOS 14.0; the core's library does not follow them. +- **Off the main thread.** The `TableProFFI` module turns on no `NonisolatedNonsendingByDefault`, so its async calls poll off the main actor (0002). +- **One SQLite in the app.** On Apple targets `tablepro-storage` builds the SQLite sqlx bundles, and the app links its own. Two copies in one process break each other's POSIX locks on the same file ([How To Corrupt An SQLite Database File](https://www.sqlite.org/howtocorrupt.html), 2.2.1). Before 1b links the library into the app, the Apple build of the core links the app's SQLite, as the Linux build links the distribution's. + +## What stays Swift + +- The UI, in AppKit and SwiftUI. +- The Keychain, which the core reaches through the `HostSecrets` callback interface. +- The CloudKit transport: the container and zone, subscriptions, the record cache, dirty and deleted tracking, push batches and the rebase of a refused push. The core sees field values and returns field values; it never names CloudKit. +- Per-device view settings: sort mode, the Recent switch and the recent count (0006). +- Every engine's plugin until slice 5. + +## The CloudKit mapping + +`tablepro_storage::cloudkit` maps the library to the records the Mac syncs today and uses only their existing fields, so no slice deploys a CloudKit schema. `modifiedAtLocal` and `schemaVersion` stay the Swift side's to stamp. + +| Record field | Core | +| --- | --- | +| `ConnectionGroup.groupId`, `parentId` | `ConnectionGroup.id`, `parent_id`; ids are written upper case, as `uuidString` gives them | +| `ConnectionGroup.name` | `name`, cleaned of control characters as on Linux; the record keeps the name as written | +| `ConnectionGroup.color` | `None`, `Red`, `Orange`, `Yellow`, `Green`, `Blue`, `Purple`, `Pink`, `Gray` for `Slate`, and `Teal`, which a Mac reads as no colour | +| `ConnectionGroup.iconName` | `GroupIcon::Named` for the 15 symbols that draw the named icons (`server.rack` for Server), `GroupIcon::Symbol` for any other well-formed SF Symbol name | +| `ConnectionGroup.sortOrder`, `Connection.sortOrder` | `order` among the siblings | +| `Connection.groupId` | `SavedConnection.group_id` | +| `Connection.isFavorite` and the Mac's favourites order | `favorites`: the order the device keeps, then any other favourite by name | +| `Connection.name` | read only to break a tie in the order; slice 2 maps it | + +Reading follows the Mac's own rules: a group whose parent the records lack, or one on a cycle, sits at the top; siblings go by `sortOrder`, a missing one reading as 0, then by name, then by id. The Mac compares names with `localizedStandardCompare`, which follows its locale; the core uses that order as it stands in English (digit runs by value, then letters without accents or case, then accents, then lower case first), which `apple/scripts/probes/check-library-name-order.sh` checks against Foundation. Only ties and favourites outside the order list depend on it, and reading never writes, so a Mac in another locale can list a tie differently without either side sending anything. A record the core cannot hold, such as a group with a blank name, is left out and left alone. + +Writing keeps every value the server holds while it still reads as the library's. A library read from records therefore writes them back unchanged, and a colour, icon or parent that only another build understands survives. A list in the order its records read keeps their `sortOrder`, a new sibling takes a free value between its neighbours, and any other change numbers the list from 0, writing only the values that moved, as the Mac does. + +A pull merges three ways. The server's records are the cache updated by the pull; each field this device changed since the cache is laid over them, which is the same edit `SyncRecordEdit` rebases when CloudKit refuses a push. A deletion wins from either side, a record with no cached copy takes the server's whole, and a record missing from the library is never read as a deletion. A group moved here that would close a cycle with a move another device pushed first keeps the server's parent: a tree cannot hold both, and laying out the cycle would root both groups and undo the other move on the next push. + +A push sends what the library differs from the cache in, so the cache may hold only records the library has read. The Swift side therefore takes every record it caches through `apply_cloud_pull`: a pull's, and a push's saved copies, which CloudKit may have rebased onto another device's change, with the cache from before the push as the base. A copy cached without that step would read as an edit here and undo the other device's change on the next push. + +## Rationale + +- **Slices keep the app shippable.** Each slice replaces one Swift store behind tests that already pass, and the next waits for it. +- **The library first.** It is the one store whose model 0006 already made the same on both sides, and it gates every later slice: connections, history and sessions all key on it. +- **The mapping lives in the core**, beside the library it maps, so its rules have Rust tests, and the Windows app gets the same rules if it ever syncs through iCloud. +- **Only existing fields.** A new field needs a Production deploy before any build writes it, and an older build would still ignore it. The order and the favourites map onto what is there. +- **No engines in the Apple library.** The Mac keeps its plugins until slice 5, so linking nine engines, DuckDB among them, would add their size and a second copy of DuckDB next to its plugin's. + +## Consequences + +- 1b must keep the Swift stores readable by the previous stable release: a beta user can turn betas off, and Sparkle never downgrades (`apple/RELEASING.md`, Storage check). +- `tablepro-ffi` has an `engines` feature, on by default. The Windows app and the tests keep every engine. +- `core.yml` builds the library for Apple silicon and runs the `TableProFFI` tests on every core change. +- A CloudKit field the core cannot hold yet stays the Swift side's until its slice, which means 1b reads and writes the library fields through the core and every other `Connection` field as today. +- Two Macs that reorder the same list at once keep the order the field-by-field merge gives, the same result the Swift merge gives today. + +## Verification + +Each slice ships when all of these pass: + +- **Rust**: the crate's tests. For slice 1, `cargo test -p tablepro-storage cloudkit` reads arbitrary records, malformed ones included, and checks that writing them back sends nothing, and plays two devices editing apart through a server that rebases each push, checking that they converge and then send nothing; `cargo test -p tablepro-ffi --test cloud_library` runs the same through the FFI. +- **Bindings**: `apple/scripts/build-core.sh` then `swift test --package-path apple/Packages/TableProFFI`, which `core.yml` runs on macOS. +- **App**: the app's existing suites for the store a slice replaces pass unchanged against the core, with new tests for the Swift side's adapter, and `ProductionSchemaParityTests` shows no new CloudKit field. + +## Alternatives considered + +- **Move the whole app at once.** One change touching every store, with no release in between to find what broke. +- **Map CloudKit in Swift.** The rules would live apart from the library they keep whole, with no Rust test, and the next app would write them again. +- **A favourites order field on the `Connection` record.** It needs a Production deploy, and an older build would neither read nor keep it. +- **Commit the generated Swift, as Windows commits its C#.** The Swift build needs the Rust library either way, so the copy saves no generator run and can drift from the library it binds. diff --git a/core/docs/decisions/README.md b/core/docs/decisions/README.md index da913098b6..119cc5dd59 100644 --- a/core/docs/decisions/README.md +++ b/core/docs/decisions/README.md @@ -15,3 +15,4 @@ The format, the statuses and how a later ADR changes an earlier one follow [the | [0005](0005-sql-server-transport.md) | SQL Server on tiberius everywhere, with Kerberos added and ODBC as a Windows option | Accepted | tiberius on every platform with SSPI Negotiate added in a fork, Entra ID tokens from the core, and an optional Windows connection type over an installed ODBC Driver 18 for Always Encrypted; msodbcsql18 is never bundled. | | [0006](0006-connection-library.md) | The connection library holds a group tree, manual order and favourites | Accepted | Groups form a tree by id, connections and groups carry an explicit order, favourites are one ordered list; version 2 group names become top-level groups. | | [0007](0007-sorting-a-result.md) | A sort names its column by name or position, else the core sorts the rows held | Accepted | `sort_reference` names a unique plain column, else its position on PostgreSQL, SQLite, SQL Server and DuckDB; otherwise, and for a statement that cannot run again, `value::order` sorts the rows held, which `ResultSet::sorted` gives the FFI as a view. | +| [0009](0009-macos-on-the-core.md) | The macOS app moves to the core in slices, starting with the connection library | Accepted | UniFFI Swift bindings in an XCFramework of the static library, without the engines, at the app's deployment target; UI, Keychain and the CloudKit transport stay Swift; the core maps the library's CloudKit records both ways with the existing fields and merges a pull field by field. | diff --git a/scripts/ci/detect-changed-paths.sh b/scripts/ci/detect-changed-paths.sh index fb0400c416..745858d2c7 100755 --- a/scripts/ci/detect-changed-paths.sh +++ b/scripts/ci/detect-changed-paths.sh @@ -90,9 +90,11 @@ case "$SUITE" in PATTERNS=('.*\.swift$' '(.*/)?\.swiftlint\.yml$' 'scripts/ci/detect-changed-paths\.sh$' '\.github/workflows/(ci|swiftlint)\.yml$') ;; linux|linux-docs) - # The Linux app builds the shared core in the Cargo workspace whose files are at the top. + # The Linux app builds the shared core in the Cargo workspace whose files are at the top. core.yml also + # builds the core's Swift bindings and runs their tests. PATTERNS=('linux/' 'core/' 'Cargo\.(toml|lock)$' '(rust-toolchain|rustfmt|clippy|deny)\.toml$' '\.config/nextest\.toml$' 'scripts/ci/detect-changed-paths\.sh$' + 'apple/Packages/TableProFFI/' 'apple/scripts/build-core\.sh$' 'apple/scripts/lib/deployment-target\.sh$' '\.github/workflows/(ci|build-linux|core|linux-packages|release-linux)\.yml$') # linux-docs decides the docs links job, which reads every path. The other Linux jobs skip # Markdown, except the files a Meson test reads and the notices the build installs. diff --git a/scripts/ci/test_detect_changed_paths.py b/scripts/ci/test_detect_changed_paths.py index 2b6a6c0cbe..3889e58449 100644 --- a/scripts/ci/test_detect_changed_paths.py +++ b/scripts/ci/test_detect_changed_paths.py @@ -82,6 +82,12 @@ def test_linux_runs_on_the_linux_tree_and_what_its_jobs_read(self): with self.subTest(path=path): self.assertEqual(self.verdict_after("linux", path), "true") + def test_linux_runs_on_the_cores_swift_bindings(self): + for path in ["apple/Packages/TableProFFI/Package.swift", "apple/scripts/build-core.sh", + "apple/scripts/lib/deployment-target.sh"]: + with self.subTest(path=path): + self.assertEqual(self.verdict_after("linux", path), "true") + def test_linux_skips_the_apple_apps(self): for path in ["apple/TablePro/App.swift", "apple/TableProMobile/App.swift", "apple/Plugins/Shared/Timeout.swift", "apple/project.yml", "apple/CHANGELOG.md", ".github/workflows/macos-tests.yml"]: From 74cb1c202cc0dfd5019063b63848721f94601529 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 01:45:59 +0700 Subject: [PATCH 2/8] ci(core): split temporary Rust and Swift verification --- .github/workflows/macos-core-verify.yml | 43 +++++++++++++++----- apple/Packages/TableProFFI/Package.swift | 4 +- core/crates/ffi/src/cloud.rs | 6 +-- core/crates/storage/src/cloudkit/merge.rs | 7 +--- core/crates/storage/src/cloudkit/mod.rs | 27 +++++------- core/crates/storage/src/cloudkit/order.rs | 3 +- core/crates/storage/src/cloudkit/tests.rs | 3 +- core/crates/storage/src/cloudkit/values.rs | 14 ++----- core/crates/storage/src/connections/cloud.rs | 5 +-- 9 files changed, 55 insertions(+), 57 deletions(-) diff --git a/.github/workflows/macos-core-verify.yml b/.github/workflows/macos-core-verify.yml index bbb7ef7a14..2d7bdd5ba5 100644 --- a/.github/workflows/macos-core-verify.yml +++ b/.github/workflows/macos-core-verify.yml @@ -23,6 +23,35 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: key: macos-core-lane + - name: Swift library and tests + run: | + apple/scripts/build-core.sh both + .claude/skills/fix-issue/scripts/verify.sh --root "$PWD" --no-wait package TableProFFI + apple/scripts/probes/check-library-name-order.sh + du -h apple/Packages/TableProFFI/Build/lib/libtablepro_ffi.a + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + if: always() + with: + name: macos-core-swift-verification + path: | + .analysis/feat-macos-core-library/logs + rust: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + RUSTFLAGS: -D warnings + CARGO_BUILD_JOBS: 2 + CARGO_INCREMENTAL: 0 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 + with: + toolchain: "1.98" + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 + with: + key: macos-core-lane-rust - name: Rust tests and lint run: | cargo test --locked -p tablepro-storage @@ -32,18 +61,10 @@ jobs: run: | cargo build --locked -p tablepro-ffi --no-default-features cargo install uniffi-bindgen-cs --locked --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.11.1+v0.31.0 - uniffi-bindgen-cs --library target/debug/libtablepro_ffi.dylib --config core/crates/ffi/uniffi.toml --no-format --out-dir windows/src/TablePro.Interop/Generated + uniffi-bindgen-cs --library target/debug/libtablepro_ffi.so --config core/crates/ffi/uniffi.toml --no-format --out-dir windows/src/TablePro.Interop/Generated cargo run --locked -p tablepro-ffi --no-default-features --example csbindgen -- windows/src/TablePro.Interop/Generated/WindowMethods.g.cs - - name: Swift library and tests - run: | - apple/scripts/build-core.sh both - .claude/skills/fix-issue/scripts/verify.sh --root "$PWD" --no-wait package TableProFFI - apple/scripts/probes/check-library-name-order.sh - du -h apple/Packages/TableProFFI/Build/lib/libtablepro_ffi.a - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 if: always() with: - name: macos-core-verification - path: | - windows/src/TablePro.Interop/Generated - .analysis/feat-macos-core-library/logs + name: macos-core-windows-bindings + path: windows/src/TablePro.Interop/Generated diff --git a/apple/Packages/TableProFFI/Package.swift b/apple/Packages/TableProFFI/Package.swift index a317a8d50e..aa7518b867 100644 --- a/apple/Packages/TableProFFI/Package.swift +++ b/apple/Packages/TableProFFI/Package.swift @@ -2,9 +2,7 @@ import PackageDescription -// The shared Rust core as the Apple apps link it. scripts/build-core.sh builds Build/TableProFFIC.xcframework and the -// generated Sources/TableProFFI/TableProFFI.swift, neither in git (core/docs/decisions/0009-macos-on-the-core.md). -// No NonisolatedNonsendingByDefault here: with it the generated async calls poll on the caller's actor, the main one. +// The shared Rust core as the Apple apps link it. scripts/build-core.sh builds Build/TableProFFIC.xcframework and the generated Sources/TableProFFI/TableProFFI.swift, neither in git (core/docs/decisions/0009-macos-on-the-core.md). No NonisolatedNonsendingByDefault here: with it the generated async calls poll on the caller's actor, the main one. let package = Package( name: "TableProFFI", platforms: [ diff --git a/core/crates/ffi/src/cloud.rs b/core/crates/ffi/src/cloud.rs index 9c1512827b..41e4d7ac40 100644 --- a/core/crates/ffi/src/cloud.rs +++ b/core/crates/ffi/src/cloud.rs @@ -4,8 +4,7 @@ use uuid::Uuid; use crate::error::CoreError; use crate::library::{ConnectionLibrary, parse_id}; -// The CloudKit records of the connection library, field for field as the Apple apps sync them -// (core/docs/decisions/0009-macos-on-the-core.md). None is a field the record lacks. +// The CloudKit records of the connection library, field for field as the Apple apps sync them (core/docs/decisions/0009-macos-on-the-core.md). None is a field the record lacks. #[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] pub struct CloudGroupFields { pub group_id: String, @@ -55,8 +54,7 @@ impl ConnectionLibrary { .await } - // The server's records with this device's unpushed edits over them; a deletion wins from either side. A push's - // saved copies come through here too, with the cache from before the push as the base, before they are cached. + // The server's records with this device's unpushed edits over them; a deletion wins from either side. A push's saved copies come through here too, with the cache from before the push as the base, before they are cached. pub async fn apply_cloud_pull(&self, pull: CloudLibraryPull) -> Result<(), CoreError> { let pull = CloudPull { base: pull.base.into(), diff --git a/core/crates/storage/src/cloudkit/merge.rs b/core/crates/storage/src/cloudkit/merge.rs index 613bb0e8af..07c8f3e23a 100644 --- a/core/crates/storage/src/cloudkit/merge.rs +++ b/core/crates/storage/src/cloudkit/merge.rs @@ -6,9 +6,7 @@ use super::values::record_id; use super::{CloudConnection, CloudGroup, CloudPull, CloudRecords, records}; use crate::ConnectionList; -/// The records the library reads after a pull: the server's, as `base` updated by the pull, with each field this -/// device changed since `base` laid over them, the same edit a push rebases onto the server's copy. A deletion -/// wins from either side, and a record this device holds no base for takes the server's copy whole. +// The records the library reads after a pull: the server's, as `base` updated by the pull, with each field this device changed since `base` laid over them, the same edit a push rebases onto the server's copy. A deletion wins from either side, and a record this device holds no base for takes the server's copy whole. pub fn merged(list: &ConnectionList, pull: &CloudPull) -> CloudRecords { let local = records(list, &pull.base); let mut groups = merge( @@ -52,8 +50,7 @@ pub fn merged(list: &ConnectionList, pull: &CloudPull) -> CloudRecords { } } -// A move made here that closes a cycle with a move another device pushed first yields to it: a tree cannot -// hold both, and laying out the cycle would root both groups and undo the other device's move on the next push. +// A move made here that closes a cycle with a move another device pushed first yields to it: a tree cannot hold both, and laying out the cycle would root both groups and undo the other device's move on the next push. fn yield_cycles(groups: &mut [CloudGroup], pull: &CloudPull, local: &[CloudGroup]) { let keyed = |records: &[CloudGroup]| -> HashMap { records diff --git a/core/crates/storage/src/cloudkit/mod.rs b/core/crates/storage/src/cloudkit/mod.rs index ec8ab6e841..3da4111c55 100644 --- a/core/crates/storage/src/cloudkit/mod.rs +++ b/core/crates/storage/src/cloudkit/mod.rs @@ -1,6 +1,4 @@ -//! The connection library as the Apple apps sync it through CloudKit: a `ConnectionGroup` record per group, and on -//! each `Connection` record the fields that place it. The app moves the records; this maps them to and from a -//! [`ConnectionList`] and merges a pull with the edits this device has not pushed yet. +// The connection library as the Apple apps sync it through CloudKit: a `ConnectionGroup` record per group, and on each `Connection` record the fields that place it. The app moves the records; this maps them to and from a [`ConnectionList`] and merges a pull with the edits this device has not pushed yet. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -24,7 +22,7 @@ use values::{ sort_key, }; -/// A `ConnectionGroup` record's fields. `None` is a field the record lacks. +// A `ConnectionGroup` record's fields. `None` is a field the record lacks. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CloudGroup { pub group_id: String, @@ -35,8 +33,7 @@ pub struct CloudGroup { pub sort_order: Option, } -/// The fields of a `Connection` record that place it in the library. The name only breaks a tie in the order: -/// [`records`] writes the one the record has, or the library's for a new record. +// The fields of a `Connection` record that place it in the library. The name only breaks a tie in the order: [`records`] writes the one the record has, or the library's for a new record. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CloudConnection { pub connection_id: String, @@ -60,19 +57,17 @@ pub struct CloudIds { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CloudPull { - /// Every library record this device held before the pull, as the server last gave it. + // Every library record this device held before the pull, as the server last gave it. pub base: CloudRecords, pub changed: CloudRecords, pub deleted: CloudIds, - /// Deleted on this device and not pushed yet. + // Deleted on this device and not pushed yet. pub deleted_here: CloudIds, - /// The favourites' order to keep, when the library does not hold it yet. + // The favourites' order to keep, when the library does not hold it yet. pub favorites_order: Option>, } -/// The library `records` read as, for the connections `list` holds: the groups they make, where each connection -/// sits, and the favourites, in `favorites_order` and then by name. A connection with no record keeps its place -/// after the ones that have one. A record the core cannot hold, such as a group with no name, is left out. +// The library `records` read as, for the connections `list` holds: the groups they make, where each connection sits, and the favourites, in `favorites_order` and then by name. A connection with no record keeps its place after the ones that have one. A record the core cannot hold, such as a group with no name, is left out. pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[Uuid]) -> ConnectionList { let groups = read_groups(&records.groups); let known: HashSet = groups.iter().map(|group| group.id).collect(); @@ -132,8 +127,7 @@ pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[ } } -/// Every record the library writes, keeping each value `base` holds wherever it reads as the library's, so a -/// library read from `base` writes `base` back, and a value only another build understands survives. +// Every record the library writes, keeping each value `base` holds wherever it reads as the library's, so a library read from `base` writes `base` back, and a value only another build understands survives. pub fn records(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { CloudRecords { groups: group_records(list, base), @@ -141,7 +135,7 @@ pub fn records(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { } } -/// The records a push sends: the new ones, and those with a field that differs from `base`. +// The records a push sends: the new ones, and those with a field that differs from `base`. pub fn edits(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { let all = records(list, base); let base_groups = by_id(&base.groups, |record| &record.group_id); @@ -176,8 +170,7 @@ fn by_id<'a, R>(records: &'a [R], id: impl Fn(&R) -> &String) -> HashMap Vec { let mut claimed = HashSet::with_capacity(records.len()); let mut seen = HashSet::with_capacity(records.len()); diff --git a/core/crates/storage/src/cloudkit/order.rs b/core/crates/storage/src/cloudkit/order.rs index 02e65b147b..c90a42b150 100644 --- a/core/crates/storage/src/cloudkit/order.rs +++ b/core/crates/storage/src/cloudkit/order.rs @@ -11,8 +11,7 @@ pub(super) struct Sibling<'a> { pub base: Option>, } -// Each sibling's `sortOrder`, given in the list's order. Values the server holds stay while they still read in -// this order, so a list nobody reordered writes nothing; otherwise the list is numbered from 0, as macOS numbers one. +// Each sibling's `sortOrder`, given in the list's order. Values the server holds stay while they still read in this order, so a list nobody reordered writes nothing; otherwise the list is numbered from 0, as macOS numbers one. pub(super) fn sort_orders(siblings: &[Sibling<'_>]) -> Vec> { keeping_base(siblings).unwrap_or_else(|| { siblings diff --git a/core/crates/storage/src/cloudkit/tests.rs b/core/crates/storage/src/cloudkit/tests.rs index 0613668565..2dbbc870ce 100644 --- a/core/crates/storage/src/cloudkit/tests.rs +++ b/core/crates/storage/src/cloudkit/tests.rs @@ -559,8 +559,7 @@ impl Device { } } - // A push saves each edit onto the server's copy, then takes the saved copies in as a pull would: a copy rebased - // onto another device's change holds fields this library has not read yet. + // A push saves each edit onto the server's copy, then takes the saved copies in as a pull would: a copy rebased onto another device's change holds fields this library has not read yet. fn push(&mut self, server: &mut Server) { let sent = self.store.cloud_edits_blocking(&self.cache).expect("the edits"); let mut saved = CloudRecords::default(); diff --git a/core/crates/storage/src/cloudkit/values.rs b/core/crates/storage/src/cloudkit/values.rs index 92f96a2fb0..eaa60e78e0 100644 --- a/core/crates/storage/src/cloudkit/values.rs +++ b/core/crates/storage/src/cloudkit/values.rs @@ -7,9 +7,7 @@ use uuid::Uuid; use crate::{ConnectionColor, ConnectionGroupName, ConnectionIcon, GroupIcon, SymbolName}; -// How a field the macOS app wrote reads in the core, and what the core writes for a value of its own. -// A value no reader here understands reads as the default, and the record keeps it until the user -// changes that field (`super::records`). +// How a field the macOS app wrote reads in the core, and what the core writes for a value of its own. A value no reader here understands reads as the default, and the record keeps it until the user changes that field (`super::records`). // As Swift's `UUID(uuidString:)` reads one: hyphenated, in either case. pub(super) fn record_id(raw: &str) -> Option { @@ -25,8 +23,7 @@ pub(super) fn group_name(raw: Option<&str>) -> Option { raw.and_then(ConnectionGroupName::from_stored) } -// The macOS `ConnectionColor` raw values a group record holds. Teal is the core's alone: a macOS build reads it -// as no colour and keeps it until someone picks another. +// The macOS `ConnectionColor` raw values a group record holds. Teal is the core's alone: a macOS build reads it as no colour and keeps it until someone picks another. const COLORS: [(ConnectionColor, &str); 9] = [ (ConnectionColor::Red, "Red"), (ConnectionColor::Orange, "Orange"), @@ -53,8 +50,7 @@ pub(super) fn color_value(color: Option) -> String { .to_owned() } -// The SF Symbol each named icon is written as. A record naming one of these reads back as the named icon, so the -// value round-trips and a library exported from a Mac carries icons every app draws. +// The SF Symbol each named icon is written as. A record naming one of these reads back as the named icon, so the value round-trips and a library exported from a Mac carries icons every app draws. const NAMED_SYMBOLS: [(ConnectionIcon, &str); 15] = [ (ConnectionIcon::Server, "server.rack"), (ConnectionIcon::Computer, "desktopcomputer"), @@ -105,9 +101,7 @@ pub(super) fn favorite_value(favorite: bool) -> i64 { i64::from(favorite) } -// The macOS tie-break between equal sort orders is `localizedStandardCompare`, then the id. Without a locale this -// follows its English order: digit runs by value, then letters without accents or case, then accents, then lower -// case first (apple/scripts/probes/check-library-name-order.sh measures Foundation against the same pairs). +// The macOS tie-break between equal sort orders is `localizedStandardCompare`, then the id. Without a locale this follows its English order: digit runs by value, then letters without accents or case, then accents, then lower case first (apple/scripts/probes/check-library-name-order.sh measures Foundation against the same pairs). pub(super) fn name_order(left: (&str, Uuid), right: (&str, Uuid)) -> Ordering { let (l, r) = (left.0, right.0); natural_text_order(&letters(l), &letters(r)) diff --git a/core/crates/storage/src/connections/cloud.rs b/core/crates/storage/src/connections/cloud.rs index cb540c8ce4..4af777c952 100644 --- a/core/crates/storage/src/connections/cloud.rs +++ b/core/crates/storage/src/connections/cloud.rs @@ -7,13 +7,12 @@ use crate::cloudkit::{self, CloudPull, CloudRecords}; use crate::error::StorageError; impl ConnectionStore { - /// The records a push sends for the library, against what the server last gave this device. + // The records a push sends for the library, against what the server last gave this device. pub fn cloud_edits_blocking(&self, base: &CloudRecords) -> Result { Ok(cloudkit::edits(&self.list_blocking()?, base)) } - /// Lays a pull over the library in one write. A connection the pull deleted leaves it; one the library does - /// not hold yet is the app's to add first. + // Lays a pull over the library in one write. A connection the pull deleted leaves it; one the library does not hold yet is the app's to add first. pub fn apply_cloud_pull_blocking(&self, pull: &CloudPull) -> Result<(), StorageError> { self.edit(|document| { let deleted: HashSet = pull.deleted.connections.iter().copied().collect(); From a115933fe39559ea8d416dddc7e3a0d4c1cf83e7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 02:00:07 +0700 Subject: [PATCH 3/8] ci(core): check Swift bindings when the deployment target changes --- apple/Packages/TableProFFI/Package.swift | 2 +- core/crates/ffi/src/cloud.rs | 4 ++-- core/crates/storage/src/cloudkit/merge.rs | 4 ++-- core/crates/storage/src/cloudkit/mod.rs | 10 +++++----- core/crates/storage/src/cloudkit/order.rs | 2 +- core/crates/storage/src/cloudkit/values.rs | 8 ++++---- core/crates/storage/src/connections/cloud.rs | 2 +- scripts/ci/detect-changed-paths.sh | 2 +- scripts/ci/test_detect_changed_paths.py | 4 ++-- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apple/Packages/TableProFFI/Package.swift b/apple/Packages/TableProFFI/Package.swift index aa7518b867..4530390a86 100644 --- a/apple/Packages/TableProFFI/Package.swift +++ b/apple/Packages/TableProFFI/Package.swift @@ -2,7 +2,7 @@ import PackageDescription -// The shared Rust core as the Apple apps link it. scripts/build-core.sh builds Build/TableProFFIC.xcframework and the generated Sources/TableProFFI/TableProFFI.swift, neither in git (core/docs/decisions/0009-macos-on-the-core.md). No NonisolatedNonsendingByDefault here: with it the generated async calls poll on the caller's actor, the main one. +// Keep generated bindings and their binary together so their UniFFI checksums agree. let package = Package( name: "TableProFFI", platforms: [ diff --git a/core/crates/ffi/src/cloud.rs b/core/crates/ffi/src/cloud.rs index 41e4d7ac40..0eef92c453 100644 --- a/core/crates/ffi/src/cloud.rs +++ b/core/crates/ffi/src/cloud.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::error::CoreError; use crate::library::{ConnectionLibrary, parse_id}; -// The CloudKit records of the connection library, field for field as the Apple apps sync them (core/docs/decisions/0009-macos-on-the-core.md). None is a field the record lacks. +// Only existing CloudKit fields cross this boundary; ADR 0009 defines their mapping. #[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)] pub struct CloudGroupFields { pub group_id: String, @@ -54,7 +54,7 @@ impl ConnectionLibrary { .await } - // The server's records with this device's unpushed edits over them; a deletion wins from either side. A push's saved copies come through here too, with the cache from before the push as the base, before they are cached. + // Apply rebased push replies here before caching them, so remote edits are not mistaken for local edits. pub async fn apply_cloud_pull(&self, pull: CloudLibraryPull) -> Result<(), CoreError> { let pull = CloudPull { base: pull.base.into(), diff --git a/core/crates/storage/src/cloudkit/merge.rs b/core/crates/storage/src/cloudkit/merge.rs index 07c8f3e23a..b91ef95b37 100644 --- a/core/crates/storage/src/cloudkit/merge.rs +++ b/core/crates/storage/src/cloudkit/merge.rs @@ -6,7 +6,7 @@ use super::values::record_id; use super::{CloudConnection, CloudGroup, CloudPull, CloudRecords, records}; use crate::ConnectionList; -// The records the library reads after a pull: the server's, as `base` updated by the pull, with each field this device changed since `base` laid over them, the same edit a push rebases onto the server's copy. A deletion wins from either side, and a record this device holds no base for takes the server's copy whole. +// Rebase local field edits over the pull, matching the host's push conflict handling. pub fn merged(list: &ConnectionList, pull: &CloudPull) -> CloudRecords { let local = records(list, &pull.base); let mut groups = merge( @@ -50,7 +50,7 @@ pub fn merged(list: &ConnectionList, pull: &CloudPull) -> CloudRecords { } } -// A move made here that closes a cycle with a move another device pushed first yields to it: a tree cannot hold both, and laying out the cycle would root both groups and undo the other device's move on the next push. +// Yield conflicting local moves so normalizing a cycle cannot undo a remote move. fn yield_cycles(groups: &mut [CloudGroup], pull: &CloudPull, local: &[CloudGroup]) { let keyed = |records: &[CloudGroup]| -> HashMap { records diff --git a/core/crates/storage/src/cloudkit/mod.rs b/core/crates/storage/src/cloudkit/mod.rs index 3da4111c55..858ed67206 100644 --- a/core/crates/storage/src/cloudkit/mod.rs +++ b/core/crates/storage/src/cloudkit/mod.rs @@ -1,4 +1,4 @@ -// The connection library as the Apple apps sync it through CloudKit: a `ConnectionGroup` record per group, and on each `Connection` record the fields that place it. The app moves the records; this maps them to and from a [`ConnectionList`] and merges a pull with the edits this device has not pushed yet. +// CloudKit transport stays in the host; the core only maps record fields. use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -33,7 +33,7 @@ pub struct CloudGroup { pub sort_order: Option, } -// The fields of a `Connection` record that place it in the library. The name only breaks a tie in the order: [`records`] writes the one the record has, or the library's for a new record. +// Names only break ordering ties; the host owns connection name edits. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CloudConnection { pub connection_id: String, @@ -67,7 +67,7 @@ pub struct CloudPull { pub favorites_order: Option>, } -// The library `records` read as, for the connections `list` holds: the groups they make, where each connection sits, and the favourites, in `favorites_order` and then by name. A connection with no record keeps its place after the ones that have one. A record the core cannot hold, such as a group with no name, is left out. +// Unknown groups stay in the record cache for a newer reader to understand. pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[Uuid]) -> ConnectionList { let groups = read_groups(&records.groups); let known: HashSet = groups.iter().map(|group| group.id).collect(); @@ -127,7 +127,7 @@ pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[ } } -// Every record the library writes, keeping each value `base` holds wherever it reads as the library's, so a library read from `base` writes `base` back, and a value only another build understands survives. +// Preserve raw fields so reading alone never writes edits back to CloudKit. pub fn records(list: &ConnectionList, base: &CloudRecords) -> CloudRecords { CloudRecords { groups: group_records(list, base), @@ -170,7 +170,7 @@ fn by_id<'a, R>(records: &'a [R], id: impl Fn(&R) -> &String) -> HashMap Vec { let mut claimed = HashSet::with_capacity(records.len()); let mut seen = HashSet::with_capacity(records.len()); diff --git a/core/crates/storage/src/cloudkit/order.rs b/core/crates/storage/src/cloudkit/order.rs index c90a42b150..121400d27b 100644 --- a/core/crates/storage/src/cloudkit/order.rs +++ b/core/crates/storage/src/cloudkit/order.rs @@ -11,7 +11,7 @@ pub(super) struct Sibling<'a> { pub base: Option>, } -// Each sibling's `sortOrder`, given in the list's order. Values the server holds stay while they still read in this order, so a list nobody reordered writes nothing; otherwise the list is numbered from 0, as macOS numbers one. +// Keep unchanged ordering values so reading a library sends no writes. pub(super) fn sort_orders(siblings: &[Sibling<'_>]) -> Vec> { keeping_base(siblings).unwrap_or_else(|| { siblings diff --git a/core/crates/storage/src/cloudkit/values.rs b/core/crates/storage/src/cloudkit/values.rs index eaa60e78e0..7052527805 100644 --- a/core/crates/storage/src/cloudkit/values.rs +++ b/core/crates/storage/src/cloudkit/values.rs @@ -7,7 +7,7 @@ use uuid::Uuid; use crate::{ConnectionColor, ConnectionGroupName, ConnectionIcon, GroupIcon, SymbolName}; -// How a field the macOS app wrote reads in the core, and what the core writes for a value of its own. A value no reader here understands reads as the default, and the record keeps it until the user changes that field (`super::records`). +// Keep unknown raw values until the user changes that field. // As Swift's `UUID(uuidString:)` reads one: hyphenated, in either case. pub(super) fn record_id(raw: &str) -> Option { @@ -23,7 +23,7 @@ pub(super) fn group_name(raw: Option<&str>) -> Option { raw.and_then(ConnectionGroupName::from_stored) } -// The macOS `ConnectionColor` raw values a group record holds. Teal is the core's alone: a macOS build reads it as no colour and keeps it until someone picks another. +// Keep macOS colour spellings so existing readers recognize the records. const COLORS: [(ConnectionColor, &str); 9] = [ (ConnectionColor::Red, "Red"), (ConnectionColor::Orange, "Orange"), @@ -50,7 +50,7 @@ pub(super) fn color_value(color: Option) -> String { .to_owned() } -// The SF Symbol each named icon is written as. A record naming one of these reads back as the named icon, so the value round-trips and a library exported from a Mac carries icons every app draws. +// Use SF Symbol names so the same record draws a known icon on macOS. const NAMED_SYMBOLS: [(ConnectionIcon, &str); 15] = [ (ConnectionIcon::Server, "server.rack"), (ConnectionIcon::Computer, "desktopcomputer"), @@ -101,7 +101,7 @@ pub(super) fn favorite_value(favorite: bool) -> i64 { i64::from(favorite) } -// The macOS tie-break between equal sort orders is `localizedStandardCompare`, then the id. Without a locale this follows its English order: digit runs by value, then letters without accents or case, then accents, then lower case first (apple/scripts/probes/check-library-name-order.sh measures Foundation against the same pairs). +// The Foundation probe checks common English name ties; other locales can order ties differently. pub(super) fn name_order(left: (&str, Uuid), right: (&str, Uuid)) -> Ordering { let (l, r) = (left.0, right.0); natural_text_order(&letters(l), &letters(r)) diff --git a/core/crates/storage/src/connections/cloud.rs b/core/crates/storage/src/connections/cloud.rs index 4af777c952..22f0072356 100644 --- a/core/crates/storage/src/connections/cloud.rs +++ b/core/crates/storage/src/connections/cloud.rs @@ -12,7 +12,7 @@ impl ConnectionStore { Ok(cloudkit::edits(&self.list_blocking()?, base)) } - // Lays a pull over the library in one write. A connection the pull deleted leaves it; one the library does not hold yet is the app's to add first. + // The host adds unknown connections before applying their placement fields. pub fn apply_cloud_pull_blocking(&self, pull: &CloudPull) -> Result<(), StorageError> { self.edit(|document| { let deleted: HashSet = pull.deleted.connections.iter().copied().collect(); diff --git a/scripts/ci/detect-changed-paths.sh b/scripts/ci/detect-changed-paths.sh index 745858d2c7..febb6d3b36 100755 --- a/scripts/ci/detect-changed-paths.sh +++ b/scripts/ci/detect-changed-paths.sh @@ -94,7 +94,7 @@ case "$SUITE" in # builds the core's Swift bindings and runs their tests. PATTERNS=('linux/' 'core/' 'Cargo\.(toml|lock)$' '(rust-toolchain|rustfmt|clippy|deny)\.toml$' '\.config/nextest\.toml$' 'scripts/ci/detect-changed-paths\.sh$' - 'apple/Packages/TableProFFI/' 'apple/scripts/build-core\.sh$' 'apple/scripts/lib/deployment-target\.sh$' + 'apple/project\.yml$' 'apple/Packages/TableProFFI/' 'apple/scripts/build-core\.sh$' 'apple/scripts/lib/deployment-target\.sh$' '\.github/workflows/(ci|build-linux|core|linux-packages|release-linux)\.yml$') # linux-docs decides the docs links job, which reads every path. The other Linux jobs skip # Markdown, except the files a Meson test reads and the notices the build installs. diff --git a/scripts/ci/test_detect_changed_paths.py b/scripts/ci/test_detect_changed_paths.py index 3889e58449..ef3072bf38 100644 --- a/scripts/ci/test_detect_changed_paths.py +++ b/scripts/ci/test_detect_changed_paths.py @@ -84,13 +84,13 @@ def test_linux_runs_on_the_linux_tree_and_what_its_jobs_read(self): def test_linux_runs_on_the_cores_swift_bindings(self): for path in ["apple/Packages/TableProFFI/Package.swift", "apple/scripts/build-core.sh", - "apple/scripts/lib/deployment-target.sh"]: + "apple/scripts/lib/deployment-target.sh", "apple/project.yml"]: with self.subTest(path=path): self.assertEqual(self.verdict_after("linux", path), "true") def test_linux_skips_the_apple_apps(self): for path in ["apple/TablePro/App.swift", "apple/TableProMobile/App.swift", "apple/Plugins/Shared/Timeout.swift", - "apple/project.yml", "apple/CHANGELOG.md", ".github/workflows/macos-tests.yml"]: + "apple/CHANGELOG.md", ".github/workflows/macos-tests.yml"]: with self.subTest(path=path): self.assertEqual(self.verdict_after("linux", path), "false") From 659ee4c42a6783809aa8a1c77cd6d809f1a29bc8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 02:04:52 +0700 Subject: [PATCH 4/8] refactor(core): name cloud connection placements --- .github/workflows/macos-core-verify.yml | 30 +------------------------ core/crates/storage/src/cloudkit/mod.rs | 26 ++++++++++++++------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/.github/workflows/macos-core-verify.yml b/.github/workflows/macos-core-verify.yml index 2d7bdd5ba5..b08001afb8 100644 --- a/.github/workflows/macos-core-verify.yml +++ b/.github/workflows/macos-core-verify.yml @@ -6,35 +6,6 @@ on: permissions: contents: read jobs: - verify: - runs-on: macos-15 - timeout-minutes: 90 - env: - RUSTFLAGS: -D warnings - CARGO_BUILD_JOBS: 2 - CARGO_INCREMENTAL: 0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 - with: - toolchain: "1.98" - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 - with: - key: macos-core-lane - - name: Swift library and tests - run: | - apple/scripts/build-core.sh both - .claude/skills/fix-issue/scripts/verify.sh --root "$PWD" --no-wait package TableProFFI - apple/scripts/probes/check-library-name-order.sh - du -h apple/Packages/TableProFFI/Build/lib/libtablepro_ffi.a - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 - if: always() - with: - name: macos-core-swift-verification - path: | - .analysis/feat-macos-core-library/logs rust: runs-on: ubuntu-24.04 timeout-minutes: 60 @@ -52,6 +23,7 @@ jobs: - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 with: key: macos-core-lane-rust + cache-on-failure: true - name: Rust tests and lint run: | cargo test --locked -p tablepro-storage diff --git a/core/crates/storage/src/cloudkit/mod.rs b/core/crates/storage/src/cloudkit/mod.rs index 858ed67206..c38694168a 100644 --- a/core/crates/storage/src/cloudkit/mod.rs +++ b/core/crates/storage/src/cloudkit/mod.rs @@ -67,13 +67,19 @@ pub struct CloudPull { pub favorites_order: Option>, } +struct ConnectionPlace { + record_key: Option<(i64, String)>, + order: u32, + id: Uuid, +} + // Unknown groups stay in the record cache for a newer reader to understand. pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[Uuid]) -> ConnectionList { let groups = read_groups(&records.groups); let known: HashSet = groups.iter().map(|group| group.id).collect(); let recorded = by_id(&records.connections, |record| &record.connection_id); - let mut places: HashMap, Vec<(Option<(i64, String)>, u32, Uuid)>> = HashMap::new(); + let mut places: HashMap, Vec> = HashMap::new(); let mut favorites: HashSet = HashSet::new(); for saved in list.connections.iter() { let record = recorded.get(&saved.id); @@ -86,7 +92,11 @@ pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[ let name = record.name.clone().unwrap_or_else(|| saved.name.clone()); (sort_key(record.sort_order), name) }); - places.entry(group).or_default().push((key, saved.order, saved.id)); + places.entry(group).or_default().push(ConnectionPlace { + record_key: key, + order: saved.order, + id: saved.id, + }); let is_favorite = record.map_or_else(|| list.is_favorite(saved.id), |record| favorite(record.is_favorite)); if is_favorite { favorites.insert(saved.id); @@ -95,14 +105,14 @@ pub fn layout(list: &ConnectionList, records: &CloudRecords, favorites_order: &[ let mut placed: HashMap, u32)> = HashMap::new(); for (group, mut members) in places { - members.sort_by(|left, right| match (&left.0, &right.0) { - (Some(l), Some(r)) => reads_before((l.0, &l.1, left.2), (r.0, &r.1, right.2)), + members.sort_by(|left, right| match (&left.record_key, &right.record_key) { + (Some(l), Some(r)) => reads_before((l.0, &l.1, left.id), (r.0, &r.1, right.id)), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => (left.1, left.2).cmp(&(right.1, right.2)), + (None, None) => (left.order, left.id).cmp(&(right.order, right.id)), }); - for ((_, _, id), rank) in members.into_iter().zip(0_u32..) { - placed.insert(id, (group, rank)); + for (member, rank) in members.into_iter().zip(0_u32..) { + placed.insert(member.id, (group, rank)); } } let connections: Vec = list @@ -160,7 +170,7 @@ fn changed(base: &HashMap, id: &str, record: &R) -> bool .is_none_or(|held| *held != record) } -fn by_id<'a, R>(records: &'a [R], id: impl Fn(&R) -> &String) -> HashMap { +fn by_id(records: &[R], id: impl Fn(&R) -> &String) -> HashMap { let mut found = HashMap::with_capacity(records.len()); for record in records { if let Some(parsed) = record_id(id(record)) { From 1674ebb90f171395ba82d03f2c8134d4985edf46 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 02:29:33 +0700 Subject: [PATCH 5/8] build(core): update bindings and record macOS verification --- .github/workflows/macos-core-verify.yml | 42 -- core/crates/storage/src/cloudkit/tests.rs | 15 +- core/docs/decisions/0009-macos-on-the-core.md | 2 + .../Generated/tablepro_ffi.cs | 395 ++++++++++++++++++ 4 files changed, 410 insertions(+), 44 deletions(-) delete mode 100644 .github/workflows/macos-core-verify.yml diff --git a/.github/workflows/macos-core-verify.yml b/.github/workflows/macos-core-verify.yml deleted file mode 100644 index b08001afb8..0000000000 --- a/.github/workflows/macos-core-verify.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: macOS core lane verification -on: - push: - branches: [feat/macos-core-library] - paths: [.github/workflows/macos-core-verify.yml] -permissions: - contents: read -jobs: - rust: - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - RUSTFLAGS: -D warnings - CARGO_BUILD_JOBS: 2 - CARGO_INCREMENTAL: 0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 - with: - toolchain: "1.98" - - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 - with: - key: macos-core-lane-rust - cache-on-failure: true - - name: Rust tests and lint - run: | - cargo test --locked -p tablepro-storage - cargo test --locked -p tablepro-ffi --no-default-features --test cloud_library - cargo clippy --locked -p tablepro-storage -p tablepro-ffi -p tablepro-bindgen --no-default-features --all-targets -- -D warnings - - name: Generate Windows bindings - run: | - cargo build --locked -p tablepro-ffi --no-default-features - cargo install uniffi-bindgen-cs --locked --git https://github.com/NordSecurity/uniffi-bindgen-cs --tag v0.11.1+v0.31.0 - uniffi-bindgen-cs --library target/debug/libtablepro_ffi.so --config core/crates/ffi/uniffi.toml --no-format --out-dir windows/src/TablePro.Interop/Generated - cargo run --locked -p tablepro-ffi --no-default-features --example csbindgen -- windows/src/TablePro.Interop/Generated/WindowMethods.g.cs - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 - if: always() - with: - name: macos-core-windows-bindings - path: windows/src/TablePro.Interop/Generated diff --git a/core/crates/storage/src/cloudkit/tests.rs b/core/crates/storage/src/cloudkit/tests.rs index 2dbbc870ce..67d79850bc 100644 --- a/core/crates/storage/src/cloudkit/tests.rs +++ b/core/crates/storage/src/cloudkit/tests.rs @@ -783,7 +783,14 @@ fn settle(devices: &mut [Device], server: &mut Server) { } } -fn shape(list: &ConnectionList) -> (Vec, Vec<(Uuid, Option, u32)>, Vec) { +#[derive(Debug, PartialEq, Eq)] +struct LibraryShape { + groups: Vec, + connections: Vec<(Uuid, Option, u32)>, + favorites: Vec, +} + +fn shape(list: &ConnectionList) -> LibraryShape { let mut groups = list.groups.to_vec(); groups.sort_by_key(|group| group.id); let mut connections: Vec<(Uuid, Option, u32)> = list @@ -794,7 +801,11 @@ fn shape(list: &ConnectionList) -> (Vec, Vec<(Uuid, Option + Task ApplyCloudPull(CloudLibraryPull @pull); + /// + Task CloudEdits(CloudLibraryRecords @base); /// Task AddFavorites(string[] @ids, string? @before); /// @@ -4025,6 +4089,46 @@ internal T CallWithPointer(Func func) } + /// + public async Task ApplyCloudPull(CloudLibraryPull @pull) {await _UniFFIAsync.UniffiRustCallAsync( + // Get rust future + CallWithPointer(thisPtr => { + return _UniFFILib.uniffi_tablepro_ffi_fn_method_connectionlibrary_apply_cloud_pull(thisPtr, FfiConverterTypeCloudLibraryPull.INSTANCE.Lower(@pull)); + }), + // Poll + (ulong future, IntPtr continuation, ulong data) => _UniFFILib.ffi_tablepro_ffi_rust_future_poll_void(future, continuation, data), + // Complete + (ulong future, ref UniffiRustCallStatus status) => {_UniFFILib.ffi_tablepro_ffi_rust_future_complete_void(future, ref status); + }, + // Free + (ulong future) => _UniFFILib.ffi_tablepro_ffi_rust_future_free_void(future), + // Error + FfiConverterTypeCoreError.INSTANCE + ); + } + + /// + public async Task CloudEdits(CloudLibraryRecords @base) { + return await _UniFFIAsync.UniffiRustCallAsync( + // Get rust future + CallWithPointer(thisPtr => { + return _UniFFILib.uniffi_tablepro_ffi_fn_method_connectionlibrary_cloud_edits(thisPtr, FfiConverterTypeCloudLibraryRecords.INSTANCE.Lower(@base)); + }), + // Poll + (ulong future, IntPtr continuation, ulong data) => _UniFFILib.ffi_tablepro_ffi_rust_future_poll_rust_buffer(future, continuation, data), + // Complete + (ulong future, ref UniffiRustCallStatus status) => { + return _UniFFILib.ffi_tablepro_ffi_rust_future_complete_rust_buffer(future, ref status); + }, + // Free + (ulong future) => _UniFFILib.ffi_tablepro_ffi_rust_future_free_rust_buffer(future), + // Lift + (result) => FfiConverterTypeCloudLibraryRecords.INSTANCE.Lift(result), + // Error + FfiConverterTypeCoreError.INSTANCE + ); + } + /// public async Task AddFavorites(string[] @ids, string? @before) {await _UniFFIAsync.UniffiRustCallAsync( // Get rust future @@ -5599,6 +5703,174 @@ public override void Write(ResultSet value, BigEndianStream stream) { +internal record CloudConnectionFields ( + string ConnectionId, + string? Name, + string? GroupId, + long? SortOrder, + long? IsFavorite +) { +} + +class FfiConverterTypeCloudConnectionFields: FfiConverterRustBuffer { + public static FfiConverterTypeCloudConnectionFields INSTANCE = new FfiConverterTypeCloudConnectionFields(); + + public override CloudConnectionFields Read(BigEndianStream stream) { + return new CloudConnectionFields( + ConnectionId: FfiConverterString.INSTANCE.Read(stream), + Name: FfiConverterOptionalString.INSTANCE.Read(stream), + GroupId: FfiConverterOptionalString.INSTANCE.Read(stream), + SortOrder: FfiConverterOptionalInt64.INSTANCE.Read(stream), + IsFavorite: FfiConverterOptionalInt64.INSTANCE.Read(stream) + ); + } + + public override int AllocationSize(CloudConnectionFields value) { + return 0 + + FfiConverterString.INSTANCE.AllocationSize(value.ConnectionId) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.Name) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.GroupId) + + FfiConverterOptionalInt64.INSTANCE.AllocationSize(value.SortOrder) + + FfiConverterOptionalInt64.INSTANCE.AllocationSize(value.IsFavorite); + } + + public override void Write(CloudConnectionFields value, BigEndianStream stream) { + FfiConverterString.INSTANCE.Write(value.ConnectionId, stream); + FfiConverterOptionalString.INSTANCE.Write(value.Name, stream); + FfiConverterOptionalString.INSTANCE.Write(value.GroupId, stream); + FfiConverterOptionalInt64.INSTANCE.Write(value.SortOrder, stream); + FfiConverterOptionalInt64.INSTANCE.Write(value.IsFavorite, stream); + } +} + + + +internal record CloudGroupFields ( + string GroupId, + string? Name, + string? Color, + string? IconName, + string? ParentId, + long? SortOrder +) { +} + +class FfiConverterTypeCloudGroupFields: FfiConverterRustBuffer { + public static FfiConverterTypeCloudGroupFields INSTANCE = new FfiConverterTypeCloudGroupFields(); + + public override CloudGroupFields Read(BigEndianStream stream) { + return new CloudGroupFields( + GroupId: FfiConverterString.INSTANCE.Read(stream), + Name: FfiConverterOptionalString.INSTANCE.Read(stream), + Color: FfiConverterOptionalString.INSTANCE.Read(stream), + IconName: FfiConverterOptionalString.INSTANCE.Read(stream), + ParentId: FfiConverterOptionalString.INSTANCE.Read(stream), + SortOrder: FfiConverterOptionalInt64.INSTANCE.Read(stream) + ); + } + + public override int AllocationSize(CloudGroupFields value) { + return 0 + + FfiConverterString.INSTANCE.AllocationSize(value.GroupId) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.Name) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.Color) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.IconName) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.ParentId) + + FfiConverterOptionalInt64.INSTANCE.AllocationSize(value.SortOrder); + } + + public override void Write(CloudGroupFields value, BigEndianStream stream) { + FfiConverterString.INSTANCE.Write(value.GroupId, stream); + FfiConverterOptionalString.INSTANCE.Write(value.Name, stream); + FfiConverterOptionalString.INSTANCE.Write(value.Color, stream); + FfiConverterOptionalString.INSTANCE.Write(value.IconName, stream); + FfiConverterOptionalString.INSTANCE.Write(value.ParentId, stream); + FfiConverterOptionalInt64.INSTANCE.Write(value.SortOrder, stream); + } +} + + + +internal record CloudLibraryPull ( + CloudLibraryRecords Base, + CloudLibraryRecords Changed, + string[] DeletedGroupIds, + string[] DeletedConnectionIds, + string[] DeletedHereGroupIds, + string[] DeletedHereConnectionIds, + string[]? FavoritesOrder +) { +} + +class FfiConverterTypeCloudLibraryPull: FfiConverterRustBuffer { + public static FfiConverterTypeCloudLibraryPull INSTANCE = new FfiConverterTypeCloudLibraryPull(); + + public override CloudLibraryPull Read(BigEndianStream stream) { + return new CloudLibraryPull( + Base: FfiConverterTypeCloudLibraryRecords.INSTANCE.Read(stream), + Changed: FfiConverterTypeCloudLibraryRecords.INSTANCE.Read(stream), + DeletedGroupIds: FfiConverterSequenceString.INSTANCE.Read(stream), + DeletedConnectionIds: FfiConverterSequenceString.INSTANCE.Read(stream), + DeletedHereGroupIds: FfiConverterSequenceString.INSTANCE.Read(stream), + DeletedHereConnectionIds: FfiConverterSequenceString.INSTANCE.Read(stream), + FavoritesOrder: FfiConverterOptionalSequenceString.INSTANCE.Read(stream) + ); + } + + public override int AllocationSize(CloudLibraryPull value) { + return 0 + + FfiConverterTypeCloudLibraryRecords.INSTANCE.AllocationSize(value.Base) + + FfiConverterTypeCloudLibraryRecords.INSTANCE.AllocationSize(value.Changed) + + FfiConverterSequenceString.INSTANCE.AllocationSize(value.DeletedGroupIds) + + FfiConverterSequenceString.INSTANCE.AllocationSize(value.DeletedConnectionIds) + + FfiConverterSequenceString.INSTANCE.AllocationSize(value.DeletedHereGroupIds) + + FfiConverterSequenceString.INSTANCE.AllocationSize(value.DeletedHereConnectionIds) + + FfiConverterOptionalSequenceString.INSTANCE.AllocationSize(value.FavoritesOrder); + } + + public override void Write(CloudLibraryPull value, BigEndianStream stream) { + FfiConverterTypeCloudLibraryRecords.INSTANCE.Write(value.Base, stream); + FfiConverterTypeCloudLibraryRecords.INSTANCE.Write(value.Changed, stream); + FfiConverterSequenceString.INSTANCE.Write(value.DeletedGroupIds, stream); + FfiConverterSequenceString.INSTANCE.Write(value.DeletedConnectionIds, stream); + FfiConverterSequenceString.INSTANCE.Write(value.DeletedHereGroupIds, stream); + FfiConverterSequenceString.INSTANCE.Write(value.DeletedHereConnectionIds, stream); + FfiConverterOptionalSequenceString.INSTANCE.Write(value.FavoritesOrder, stream); + } +} + + + +internal record CloudLibraryRecords ( + CloudGroupFields[] Groups, + CloudConnectionFields[] Connections +) { +} + +class FfiConverterTypeCloudLibraryRecords: FfiConverterRustBuffer { + public static FfiConverterTypeCloudLibraryRecords INSTANCE = new FfiConverterTypeCloudLibraryRecords(); + + public override CloudLibraryRecords Read(BigEndianStream stream) { + return new CloudLibraryRecords( + Groups: FfiConverterSequenceTypeCloudGroupFields.INSTANCE.Read(stream), + Connections: FfiConverterSequenceTypeCloudConnectionFields.INSTANCE.Read(stream) + ); + } + + public override int AllocationSize(CloudLibraryRecords value) { + return 0 + + FfiConverterSequenceTypeCloudGroupFields.INSTANCE.AllocationSize(value.Groups) + + FfiConverterSequenceTypeCloudConnectionFields.INSTANCE.AllocationSize(value.Connections); + } + + public override void Write(CloudLibraryRecords value, BigEndianStream stream) { + FfiConverterSequenceTypeCloudGroupFields.INSTANCE.Write(value.Groups, stream); + FfiConverterSequenceTypeCloudConnectionFields.INSTANCE.Write(value.Connections, stream); + } +} + + + /// /// The type as the server spells it, for display. /// @@ -8921,6 +9193,37 @@ public override void Write(TlsModeTag? value, BigEndianStream stream) { +class FfiConverterOptionalSequenceString: FfiConverterRustBuffer { + public static FfiConverterOptionalSequenceString INSTANCE = new FfiConverterOptionalSequenceString(); + + public override string[]? Read(BigEndianStream stream) { + if (stream.ReadByte() == 0) { + return null; + } + return FfiConverterSequenceString.INSTANCE.Read(stream); + } + + public override int AllocationSize(string[]? value) { + if (value == null) { + return 1; + } else { + return 1 + FfiConverterSequenceString.INSTANCE.AllocationSize((string[])value); + } + } + + public override void Write(string[]? value, BigEndianStream stream) { + if (value == null) { + stream.WriteByte(0); + } else { + stream.WriteByte(1); + FfiConverterSequenceString.INSTANCE.Write((string[])value, stream); + } + } +} + + + + class FfiConverterSequenceString: FfiConverterRustBuffer { public static FfiConverterSequenceString INSTANCE = new FfiConverterSequenceString(); @@ -8967,6 +9270,98 @@ public override void Write(string[] value, BigEndianStream stream) { +class FfiConverterSequenceTypeCloudConnectionFields: FfiConverterRustBuffer { + public static FfiConverterSequenceTypeCloudConnectionFields INSTANCE = new FfiConverterSequenceTypeCloudConnectionFields(); + + public override CloudConnectionFields[] Read(BigEndianStream stream) { + var length = stream.ReadInt(); + if (length == 0) { + return []; + } + + var result = new CloudConnectionFields[length]; + var readFn = FfiConverterTypeCloudConnectionFields.INSTANCE.Read; + for (int i = 0; i < length; i++) { + result[i] = readFn(stream); + } + return result; + } + + public override int AllocationSize(CloudConnectionFields[] value) { + var sizeForLength = 4; + + // details/1-empty-list-as-default-method-parameter.md + if (value == null) { + return sizeForLength; + } + + var allocationSizeFn = FfiConverterTypeCloudConnectionFields.INSTANCE.AllocationSize; + var sizeForItems = value.Sum(item => allocationSizeFn(item)); + return sizeForLength + sizeForItems; + } + + public override void Write(CloudConnectionFields[] value, BigEndianStream stream) { + // details/1-empty-list-as-default-method-parameter.md + if (value == null) { + stream.WriteInt(0); + return; + } + + stream.WriteInt(value.Length); + var writerFn = FfiConverterTypeCloudConnectionFields.INSTANCE.Write; + value.ForEach(item => writerFn(item, stream)); + } +} + + + + +class FfiConverterSequenceTypeCloudGroupFields: FfiConverterRustBuffer { + public static FfiConverterSequenceTypeCloudGroupFields INSTANCE = new FfiConverterSequenceTypeCloudGroupFields(); + + public override CloudGroupFields[] Read(BigEndianStream stream) { + var length = stream.ReadInt(); + if (length == 0) { + return []; + } + + var result = new CloudGroupFields[length]; + var readFn = FfiConverterTypeCloudGroupFields.INSTANCE.Read; + for (int i = 0; i < length; i++) { + result[i] = readFn(stream); + } + return result; + } + + public override int AllocationSize(CloudGroupFields[] value) { + var sizeForLength = 4; + + // details/1-empty-list-as-default-method-parameter.md + if (value == null) { + return sizeForLength; + } + + var allocationSizeFn = FfiConverterTypeCloudGroupFields.INSTANCE.AllocationSize; + var sizeForItems = value.Sum(item => allocationSizeFn(item)); + return sizeForLength + sizeForItems; + } + + public override void Write(CloudGroupFields[] value, BigEndianStream stream) { + // details/1-empty-list-as-default-method-parameter.md + if (value == null) { + stream.WriteInt(0); + return; + } + + stream.WriteInt(value.Length); + var writerFn = FfiConverterTypeCloudGroupFields.INSTANCE.Write; + value.ForEach(item => writerFn(item, stream)); + } +} + + + + class FfiConverterSequenceTypeColumnEntry: FfiConverterRustBuffer { public static FfiConverterSequenceTypeColumnEntry INSTANCE = new FfiConverterSequenceTypeColumnEntry(); From 827465f7019ef60306a2a653a10c8de0ddd99873 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 02:38:13 +0700 Subject: [PATCH 6/8] ci(core): register the standalone Swift binding tests --- apple/scripts/ci/check-test-plans.py | 3 ++- apple/scripts/ci/test_check_test_plans.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apple/scripts/ci/check-test-plans.py b/apple/scripts/ci/check-test-plans.py index aca23bf891..eb78df5c03 100755 --- a/apple/scripts/ci/check-test-plans.py +++ b/apple/scripts/ci/check-test-plans.py @@ -21,7 +21,8 @@ PROJECT_CONTAINER = "container:TablePro.xcodeproj" # TableProCore test targets depend on the package TableProPluginKit; in the app's build they would make # the app and plugins compile against that non-resilient module, so both packages run under swift test. -SWIFT_TEST_PACKAGES = {"TableProCore", "TableProOracle"} +# TableProFFI runs in core.yml because the app does not link it yet. +SWIFT_TEST_PACKAGES = {"TableProCore", "TableProOracle", "TableProFFI"} PLAN_KINDS = {"Unit": "bundle.unit-test", "UI": "bundle.ui-testing"} TOP_LEVEL_KEY = re.compile(r"^([A-Za-z_][\w-]*):") diff --git a/apple/scripts/ci/test_check_test_plans.py b/apple/scripts/ci/test_check_test_plans.py index 0c51368ede..c634ffbc37 100644 --- a/apple/scripts/ci/test_check_test_plans.py +++ b/apple/scripts/ci/test_check_test_plans.py @@ -55,6 +55,7 @@ "Kit": '.testTarget(name: "KitTests", dependencies: ["Kit"])\n// .testTarget(name: "RetiredTests")', "TableProCore": '.testTarget(\n name: "CoreTypesTests"\n)', "TableProOracle": '.testTarget(name: "OracleCoreTests")', + "TableProFFI": '.testTarget(name: "TableProFFITests")', } UNIT = [ From fbc1ecff7dbd5f846404b8c0c2f0d338dbccaf48 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 03:07:01 +0700 Subject: [PATCH 7/8] docs(core): record local macOS binding verification --- core/docs/decisions/0009-macos-on-the-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/docs/decisions/0009-macos-on-the-core.md b/core/docs/decisions/0009-macos-on-the-core.md index c27f0a8bb5..6f9b9f2033 100644 --- a/core/docs/decisions/0009-macos-on-the-core.md +++ b/core/docs/decisions/0009-macos-on-the-core.md @@ -91,7 +91,7 @@ Each slice ships when all of these pass: - **Bindings**: `apple/scripts/build-core.sh` then `swift test --package-path apple/Packages/TableProFFI`, which `core.yml` runs on macOS. - **App**: the app's existing suites for the store a slice replaces pass unchanged against the core, with new tests for the Swift side's adapter, and `ProductionSchemaParityTests` shows no new CloudKit field. -The hosted [verification run 41696940325](https://github.com/TableProApp/TablePro/actions/runs/41696940325/job/126129842605) built both macOS architectures with Rust 1.98. The universal archive occupied 43 MiB, and its highest recorded minimum OS was macOS 13.0. The package linked with its declared system libraries and passed all three Swift tests. The Foundation ordering probe passed all 16 pairs. +Local verification on 2026-11-04 built both macOS architectures with Rust 1.98. The universal archive was 44,684,712 bytes (about 43 MiB), and its highest recorded minimum OS was macOS 13.0. The package linked with its declared system libraries and passed all three Swift tests on Apple silicon. The Foundation ordering probe passed all 16 pairs. ## Alternatives considered From d5d1499998f0ce13f7bab16bcb2641716397ee27 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 4 Nov 2026 14:49:05 +0700 Subject: [PATCH 8/8] fix(linux): install the Debian package without rebuilding --- linux/build-aux/debian/rules | 9 +++- linux/build-aux/tests/test_debian_install.py | 47 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 linux/build-aux/tests/test_debian_install.py diff --git a/linux/build-aux/debian/rules b/linux/build-aux/debian/rules index f309512626..aea8f1fba6 100755 --- a/linux/build-aux/debian/rules +++ b/linux/build-aux/debian/rules @@ -1,6 +1,9 @@ #!/usr/bin/make -f include /usr/share/dpkg/buildopts.mk +include /usr/share/dpkg/architecture.mk + +builddir := obj-$(DEB_HOST_GNU_TYPE) # rustc-1.97 and cargo-1.97 install rustc, rustdoc and cargo here, and # Meson looks for cargo on PATH only. 1.97 is the workspace rust-version. @@ -33,7 +36,11 @@ export DEB_BUILD_MAINT_OPTIONS = hardening=+all optimize=-lto # The tarball holds the repository's layout: the Meson project is linux/, # and the Cargo workspace and vendor/ are at the top. %: - dh $@ --buildsystem=meson --sourcedirectory=linux + dh $@ --buildsystem=meson --sourcedirectory=linux --builddirectory=$(builddir) + +# Tests change Cargo's LTO profile; install the package binaries already built before them. +override_dh_auto_install: + meson install -C $(builddir) --no-rebuild --destdir "$(CURDIR)/debian/tablepro" # The upstream changelog is the Linux app's, not a file at the top. override_dh_installchangelogs: diff --git a/linux/build-aux/tests/test_debian_install.py b/linux/build-aux/tests/test_debian_install.py new file mode 100644 index 0000000000..812cc4f531 --- /dev/null +++ b/linux/build-aux/tests/test_debian_install.py @@ -0,0 +1,47 @@ +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +RULES = Path(__file__).resolve().parent.parent / "debian" / "rules" +AVAILABLE = all(shutil.which(tool) for tool in ("meson", "ninja", "make", "dpkg-architecture")) +AVAILABLE = AVAILABLE and Path("/usr/share/dpkg/buildopts.mk").is_file() + + +@unittest.skipUnless(AVAILABLE, "requires Meson and Debian build tools") +class DebianInstallTest(unittest.TestCase): + def test_install_preserves_built_binaries_without_running_builder(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "debian").mkdir() + shutil.copyfile(RULES, root / "debian" / "rules") + source = root / "linux" + source.mkdir() + (source / "meson.build").write_text( + "project('install-regression', version: '1')\n" + "custom_target('cargo-build', build_always_stale: true, " + "build_by_default: true, output: ['tablepro', 'tablepro-askpass'], " + "command: ['sh', files('build.sh'), '@OUTPUT0@', '@OUTPUT1@'], " + "install: true, install_dir: ['bin', 'libexec'])\n" + "install_data('data.txt', install_dir: 'share/tablepro')\n" + ) + builder = source / "build.sh" + builder.write_text('printf app > "$1"\nprintf askpass > "$2"\n') + (source / "data.txt").write_text("package data") + triple = subprocess.check_output(["dpkg-architecture", "-qDEB_HOST_GNU_TYPE"], text=True).strip() + build = root / f"obj-{triple}" + subprocess.run(["meson", "setup", str(build), str(source), "--prefix=/usr"], check=True) + subprocess.run(["meson", "compile", "-C", str(build)], check=True) + builder.write_text("exit 99\n") + + subprocess.run(["make", "-f", "debian/rules", "override_dh_auto_install"], cwd=root, check=True) + + staged = root / "debian" / "tablepro" / "usr" + self.assertEqual((staged / "bin" / "tablepro").read_text(), "app") + self.assertEqual((staged / "libexec" / "tablepro-askpass").read_text(), "askpass") + self.assertEqual((staged / "share" / "tablepro" / "data.txt").read_text(), "package data") + + +if __name__ == "__main__": + unittest.main()