From 04ffc31d498c7cc0ea90d23088730fe5cbd683ea Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 18 Sep 2026 15:39:07 +0200 Subject: [PATCH 01/18] feat(swift-sdk)!: freeze persistence schemas after App Store publication --- .../workflows/swift-sdk-freeze-release.yml | 61 ++ .github/workflows/tests.yml | 13 +- packages/swift-sdk/SCHEMA_RELEASES.md | 104 +++ .../Persistence/DashModelContainer.swift | 129 +--- ...SchemaV2+PersistentTrackedMasternode.swift | 42 -- .../DashSchemaV3+PersistentAssetLock.swift | 102 --- .../Models/PersistentAssetLock.swift | 14 +- .../DashModelMigrationTests.swift | 606 +----------------- ...DashReleasedSchemaRegistry.generated.swift | 8 + .../DashReleasedSchemaTests.swift | 109 ++++ .../DashSchemaFixtureSupport.swift | 135 ++++ .../DashSchemaReleaseCaptureTests.swift | 30 + .../Fixtures/SchemaStores/dash-v2.store | Bin 659456 -> 0 bytes .../Fixtures/SchemaStores/dash-v3.store | Bin 659456 -> 0 bytes .../Fixtures/SchemaStores/dash-v4.store | Bin 663552 -> 0 bytes packages/swift-sdk/schema-models.json | 55 ++ packages/swift-sdk/schema-releases.json | 5 + .../scripts/freeze_appstore_release.py | 359 +++++++++++ .../swift-sdk/scripts/freeze_schema_models.py | 339 +++++++--- .../scripts/test_freeze_appstore_release.py | 331 ++++++++++ .../scripts/test_freeze_schema_models.py | 140 +++- 21 files changed, 1623 insertions(+), 959 deletions(-) create mode 100644 .github/workflows/swift-sdk-freeze-release.yml create mode 100644 packages/swift-sdk/SCHEMA_RELEASES.md delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift delete mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store create mode 100644 packages/swift-sdk/schema-models.json create mode 100644 packages/swift-sdk/schema-releases.json create mode 100644 packages/swift-sdk/scripts/freeze_appstore_release.py create mode 100644 packages/swift-sdk/scripts/test_freeze_appstore_release.py diff --git a/.github/workflows/swift-sdk-freeze-release.yml b/.github/workflows/swift-sdk-freeze-release.yml new file mode 100644 index 00000000000..36a92b395d0 --- /dev/null +++ b/.github/workflows/swift-sdk-freeze-release.yml @@ -0,0 +1,61 @@ +name: Freeze SwiftData App Store release + +on: + workflow_dispatch: + inputs: + release_id: + description: App Store version ID recorded by the iOS publication monitor + required: true + type: string + data_commit: + description: Full commit on dashwallet-ios/schema-release-data containing the publication proof + required: true + type: string + dry_run: + description: Validate and generate without committing, pushing or opening a PR + default: false + type: boolean + +permissions: + contents: read + +# Different releases may share a schema and therefore an automation branch. +concurrency: + group: swift-sdk-appstore-schema-freeze + cancel-in-progress: false + +jobs: + freeze: + if: github.repository == 'dashpay/platform' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Checkout reviewed worker + uses: actions/checkout@v4 + with: + ref: v4.2-dev + path: platform + fetch-depth: 0 + persist-credentials: false + - name: Checkout publication records from the fixed iOS repository + uses: actions/checkout@v4 + with: + repository: dashpay/dashwallet-ios + ref: schema-release-data + path: release-data + fetch-depth: 0 + token: ${{ secrets.SCHEMA_RELEASE_TOKEN }} + persist-credentials: false + - name: Validate publication and prepare draft snapshot PR + env: + SCHEMA_RELEASE_TOKEN: ${{ secrets.SCHEMA_RELEASE_TOKEN }} + RELEASE_ID: ${{ inputs.release_id }} + DATA_COMMIT: ${{ inputs.data_commit }} + DRY_RUN: ${{ inputs.dry_run }} + working-directory: platform + shell: bash + run: | + set -euo pipefail + args=(--release-id "$RELEASE_ID" --data-commit "$DATA_COMMIT" --data-repo ../release-data) + if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi + python3 packages/swift-sdk/scripts/freeze_appstore_release.py "${args[@]}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86d9675f743..6e7a854c733 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,11 @@ concurrency: jobs: check-secrets: name: Check secret availability - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || !github.event.pull_request.draft }} + if: >- + ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || !github.event.pull_request.draft + || (github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'codex/freeze-swift-schema-v')) }} runs-on: ubuntu-24.04 outputs: has_ecr: ${{ steps.check.outputs.has_ecr }} @@ -39,7 +43,11 @@ jobs: changes: name: Determine changed packages - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || !github.event.pull_request.draft }} + if: >- + ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + || !github.event.pull_request.draft + || (github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'codex/freeze-swift-schema-v')) }} runs-on: ubuntu-24.04 outputs: js-packages: ${{ steps.override.outputs.js-packages || steps.prune-pr-matrix.outputs.js-packages || steps.filter-js.outputs.changes }} @@ -307,6 +315,7 @@ jobs: filters: | swift-sdk-changed: - .github/workflows/swift-sdk-build.yml + - .github/workflows/swift-sdk-freeze-release.yml - .github/workflows/tests.yml - packages/swift-sdk/** - packages/dapi-grpc/** diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md new file mode 100644 index 00000000000..c502c7dcd9f --- /dev/null +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -0,0 +1,104 @@ +# App Store schema snapshots + +SwiftData schemas become supported history when a build reaches App Store +distribution. TestFlight uploads capture provenance and a synthetic SQLite +fixture, but do not by themselves register a released schema. V1 is the agreed +existing baseline. Intermediate pre-release schemas have been collapsed into +the working V2; databases from those old development builds are unsupported. + +## Release flow + +The iOS release workflow saves an immutable build manifest and content-addressed +fixture on `dashpay/dashwallet-ios`'s `schema-release-data` branch. Its publication +monitor polls App Store Connect twice daily, or on manual request, and matches +the published version to the exact Apple build. A publication proof refers to +that build's manifest and its digest. The manifest contains the full Platform +commit; the latest development commit is never substituted. +Versions marked `REPLACED_WITH_NEW_VERSION` also count as published history: +a release superseded between the twice-daily checks still requires its snapshot. + +The monitor dispatches **Freeze SwiftData App Store release** with `release_id` +and `data_commit`. The worker verifies that this commit belongs to the metadata +branch, verifies the proof, build identity and artifact digests, and generates +the snapshot from the manifest's Platform commit. It uses a temporary clone; +the source checkout is unchanged. + +The worker opens a draft PR on `codex/freeze-swift-schema-v`, +targeting `v4.2-dev`. The PR contains the generated snapshot, synthetic fixture +and release association in `schema-releases.json`. Standard Swift SDK checks +run on these same-repository draft branches. A maintainer must review and merge +the PR; the worker never enables auto-merge or updates runtime model types. +The iOS monitor considers the release handled only once its registry entry is +merged. New production-capable uploads must use a Platform commit containing +all required releases, not merely a commit from before the snapshot merge. + +## Setup and manual operation + +1. Deploy the generator, registry and worker to `v4.2-dev`, and register the + dispatch workflow on the repository's default branch. GitHub requires the + workflow to exist on the default branch for `workflow_dispatch`. +2. Configure `SCHEMA_RELEASE_TOKEN` in both repositories: a fine-grained PAT + limited to `dashpay/platform` and `dashpay/dashwallet-ios`, with repository + Contents, Actions and Pull requests permissions needed by the workflow. + The Platform worker needs metadata read access, Platform branch/PR write + access, and uses the PAT so its PR events trigger CI. Apple credentials stay + in the iOS repository. Do not put tokens in command arguments or manifests. +3. Initialize the iOS release baseline before the first newly tracked release, + then run its monitor manually in dry-run mode. The baseline accepts V1 as + agreed and does not claim to reconstruct an earlier binary. +4. After publication, use the iOS manual monitor for the normal operator flow. + For a worker retry, select the recorded Apple version ID and a full commit + on `schema-release-data`. Select `dry_run` to validate and generate the patch + without committing, pushing or creating a PR. Dry runs still require read + credentials and query GitHub. + +Do not use the Platform workflow to bypass App Store publication: it requires +a published-state proof written by the trusted iOS monitor. Protect the data +branch against deletion/force-push and restrict write access to release operators +and automation. On PAT expiry, replace the repository secret in both repos and +retry; no schema should be regenerated manually just to recover authentication. + +## Developing the next schema + +A released snapshot has its own namespace, for example `DashSchemaSnapshotV2`. +It is not automatically registered alongside identical current models. When +changing the structure after a release, explicitly register the historical +snapshot as the old schema, introduce the next active version and its migration, +and retain the released fixture. A release with unchanged version, hashes and +indexes associates another App Store version with the existing snapshot. + +If development changes while Apple reviews a build, freezing still uses the +uploaded commit. CI must expose any missing migration or incompatible current +models; reconcile the next version and migration in the draft PR before merging. +Never roll development back automatically and never change a released fixture +or snapshot to make a test pass. A changed structure under an already released +schema number is an error, including changes to SQLite indexes (which entity +hashes alone do not cover). + +Fixtures contain synthetic records created by the release code on an arm64 +simulator in Release configuration. They are not extracted from the device IPA +and never contain user wallet material. + +## Retries and recovery + +- Re-running the monitor or worker reuses the deterministic branch and open PR. + The worker merges current development into the branch, preserves human edits, + and pushes without force. Resolve merge conflicts manually before retrying. +- A push that succeeds before PR creation fails is recovered on the next run. + If the PR was closed without merge, reopen it deliberately before retrying. +- A missing build record, changed digest, conflicting schema number or + rewritten release association stops processing. Restore the correct original + record through the release-data recovery process; never guess a source SHA. +- An unsupported development database may require an explicit app-data reset + by its owner. Export/recover any needed development wallet first. The app does + not silently erase an unrecognized database to make migrations succeed. + +Run the automation tests with: + +```sh +python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' +python3 packages/swift-sdk/scripts/freeze_schema_models.py --check +``` + +Swift SDK CI additionally checks the generated schemas against the released +fixtures, including entity hashes, indexes and migration behavior. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2d66c4b8ea3..870c8993494 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,26 +3,8 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { - /// The wallet-and-platform model graph as every released schema version - /// registered it, built from the frozen copies under `FrozenSchemas/` - /// and parameterised on the one slot whose frozen shape differs between - /// versions (`PersistentAssetLock`, which V3 changed). - /// - /// Every entry is a nested frozen type, never a live one. A released - /// version's checksum is the hash of every entity it declares — and a - /// relationship binds its destination by entity NAME, so a version that - /// mixed one live model into an otherwise frozen graph would have that - /// live model's current shape hashed into it (the entity name resolves - /// to whichever Swift type claimed it first in the process). Freezing - /// the whole relationship-connected graph per version is what keeps a - /// released checksum stable no matter what the live models do next, and - /// `DashModelMigrationTests` proves it against stores an older build - /// actually wrote. - /// - /// Ordering is load-bearing only in the sense that it must not need to - /// change: keeping each model in the slot its live counterpart occupies - /// makes a frozen version's list positionally identical to what that - /// version shipped. + /// Accepted V1 graph. Every registered type stays frozen: relationship + /// destinations can otherwise rebind by entity name to a changed live type. private static func frozenModelGraph( assetLock: any PersistentModel.Type ) -> [any PersistentModel.Type] { @@ -70,33 +52,9 @@ public enum DashModelContainer { frozenModelGraph(assetLock: DashSchemaV1.PersistentAssetLock.self) } - /// The exact model set registered as schema V2 — V1 plus - /// `PersistentTrackedMasternode`. Frozen for the same reason as - /// `v1ModelTypes`. - fileprivate static var v2ModelTypes: [any PersistentModel.Type] { - v1ModelTypes + [DashSchemaV2.PersistentTrackedMasternode.self] - } - - /// The exact model set registered as schema V3 — V2 with the asset-lock - /// shape that gained `recipientIsExternal`. Frozen for the same reason - /// as `v1ModelTypes`. - fileprivate static var v3ModelTypes: [any PersistentModel.Type] { - frozenModelGraph(assetLock: DashSchemaV3.PersistentAssetLock.self) - + [DashSchemaV2.PersistentTrackedMasternode.self] - } - - /// All persistent model types in the current Dash SDK schema (V4). - /// Unlike the released versions above this list tracks the LIVE models, - /// so it moves whenever a model gains a property — which is exactly why - /// the released versions must not. When the next property lands: freeze - /// every model here into the version being retired - /// (`scripts/freeze_schema_models.py`), add a version, add a stage, and - /// commit a store written by this build for the new version under the - /// test fixtures (`DashModelMigrationTests.testWriteTheLiveSchemaFixtureStore`). - /// `DashModelMigrationTests` proves a version's shape (what the entity - /// hash covers, plus its indexes) only against such a store, for the - /// live version too: changing a model here before the version ships - /// means rewriting the live fixture on purpose in the same change. + /// Live models for the next App Store schema. A release snapshot does not + /// replace these types until a subsequent shape change introduces a new live + /// version; callers must continue fetching the top-level model types. public static var modelTypes: [any PersistentModel.Type] { [ PersistentIdentity.self, @@ -139,7 +97,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV2.self) } /// Create a persistent model container for storing data @@ -206,14 +164,12 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV1.self, DashSchemaV2.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self) ] } } @@ -350,72 +306,19 @@ public enum DashSchemaV1: VersionedSchema { } } -/// Version 2 adds wallet-independent tracked masternodes. The new model has -/// no relationship or required-data dependency on V1 rows, so a lightweight -/// migration preserves every existing row and creates its table. +/// Unreleased V2 combines the tracked-masternode, asset-lock recipient, and +/// sweep additions. V1 is the accepted historical baseline. Intermediate beta +/// layouts are not supported release schemas. +/// +/// After App Store publication a separate DashSchemaSnapshotV2 preserves the +/// complete graph. Keep this version on the live models while they match that +/// snapshot. The next shape change must move this version onto the snapshot, +/// introduce a new live version and explicitly test its migration. public enum DashSchemaV2: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(2, 0, 0) } - public static var models: [any PersistentModel.Type] { - DashModelContainer.v2ModelTypes - } -} - -/// Version 3 adds `recipientIsExternal` to `PersistentAssetLock` — an -/// optional column on an existing entity, so a lightweight migration -/// preserves every existing row and backfills `NULL`. -/// -/// This is the first version to be registered alongside a genuinely frozen -/// copy of the model it changes (`DashSchemaV1.PersistentAssetLock`). Without -/// that copy, adding the property would have mutated V1's and V2's checksums -/// in place and a store written by the V2 binary would have matched no -/// registered schema, failing to open with Cocoa error 134504 rather than -/// migrating. Follow the same pattern for the next property added to any -/// model: freeze the old shape, add a version, add a stage. -public enum DashSchemaV3: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(3, 0, 0) - } - - public static var models: [any PersistentModel.Type] { - DashModelContainer.v3ModelTypes - } -} - -/// Version 4 adds the sweep columns, on the same entity set as V3: -/// - `PersistentTxo.supersededByTxid` (optional) and -/// `PersistentPendingInput.isSweptTombstone` (defaulted `false`). -/// Together they let a sweep's claim on an input whose funding TXO -/// hasn't arrived yet survive the loser transaction's deletion — -/// previously that claim lived only on the doomed row's -/// `PersistentPendingInput`, which cascades away with it. Existing -/// rows migrate as ordinary (non-tombstone, non-superseded) entries. -/// - `PersistentPendingInput.winnerMinedHeight` (optional — a -/// block-context sweep tombstone's finality stamp, the winner's own -/// mined height) and `PersistentWallet.lastAppliedChainLockHeight` -/// (optional — the numeric chainlock watermark delivered by -/// `on_persist_wallet_changeset_chain_lock_height_fn`, stored -/// monotonic-max). Together they drive the bounded tombstone lifetime: -/// a tombstone is collected exactly when -/// `min(chainlockHeight, syncedHeight)` reaches its stamp. -/// Pre-existing rows read as unstamped (held forever) over a wallet -/// with no boundary yet. -/// - The `(walletId, isSweptTombstone)` index on -/// `PersistentPendingInput`, serving the collector's tombstone-only -/// scan. -/// Every column is additive with a default or optional and the index is -/// additive, so a lightweight migration preserves each existing row. -/// -/// Registering it required freezing every model V1–V3 register — the -/// generated copies under `FrozenSchemas/`, see -/// `scripts/freeze_schema_models.py`. -public enum DashSchemaV4: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(4, 0, 0) - } - public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift deleted file mode 100644 index b4c09ccc3c1..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV2+PersistentTrackedMasternode.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTrackedMasternode` exactly as schema DashSchemaV2 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 5f58417079. -// Do not edit: every stored property, its optionality and default, and -// every @Attribute / @Relationship / #Unique here is an input to that -// version's checksum, and every #Index to the store's SQLite indexes; -// changing any of them re-breaks the stores this copy exists to keep -// openable. See the live model for what each column means. -extension DashSchemaV2 { - @Model - final class PersistentTrackedMasternode { - #Unique([\.networkRaw, \.proTxHash]) - #Index([\.networkRaw]) - - var networkRaw: UInt32 - var proTxHash: Data - var label: String? - var addedAt: UInt64 - var snapshotJSON: String - - var network: Network? { - get { Network(rawValue: networkRaw) } - set { networkRaw = newValue?.rawValue ?? networkRaw } - } - - init( - networkRaw: UInt32, - proTxHash: Data, - label: String?, - addedAt: UInt64, - snapshotJSON: String - ) { - self.networkRaw = networkRaw - self.proTxHash = proTxHash - self.label = label - self.addedAt = addedAt - self.snapshotJSON = snapshotJSON - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift deleted file mode 100644 index d54a4343f1f..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV3+PersistentAssetLock.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentAssetLock` exactly as schema DashSchemaV3 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 5f58417079. -// Do not edit: every stored property, its optionality and default, and -// every @Attribute / @Relationship / #Unique here is an input to that -// version's checksum, and every #Index to the store's SQLite indexes; -// changing any of them re-breaks the stores this copy exists to keep -// openable. See the live model for what each column means. -extension DashSchemaV3 { - @Model - final class PersistentAssetLock { - #Index([\.walletId]) - - @Attribute(.unique) var outPointHex: String - - var walletId: Data - - var transactionBytes: Data - - var fundingTypeRaw: Int - - var identityIndexRaw: Int32 - - var accountIndexRaw: Int32 = 0 - - var amountDuffs: Int64 - - var statusRaw: Int - - var proofBytes: Data? - - var recipientPlatformAddressHash: Data? - - var recipientPlatformAddressType: UInt8? - - var recipientIsExternal: Bool? - - var createdAt: Date - var updatedAt: Date - - init( - outPointHex: String, - walletId: Data, - transactionBytes: Data, - fundingTypeRaw: Int, - identityIndexRaw: Int32, - accountIndexRaw: Int32 = 0, - amountDuffs: Int64, - statusRaw: Int, - proofBytes: Data? = nil - ) { - self.outPointHex = outPointHex - self.walletId = walletId - self.transactionBytes = transactionBytes - self.fundingTypeRaw = fundingTypeRaw - self.identityIndexRaw = identityIndexRaw - self.accountIndexRaw = accountIndexRaw - self.amountDuffs = amountDuffs - self.statusRaw = statusRaw - self.proofBytes = proofBytes - self.createdAt = Date() - self.updatedAt = Date() - } - } -} - -extension DashSchemaV3.PersistentAssetLock { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in - entry.walletId == walletId - } - } - - static func predicate( - walletId: Data, - identityIndex: UInt32 - ) -> Predicate { - let identityIndexRaw = Int32(bitPattern: identityIndex) - return #Predicate { entry in - entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw - } - } -} - -extension DashSchemaV3.PersistentAssetLock { - static func encodeOutPoint(rawBytes: Data) -> String { - precondition(rawBytes.count == 36, "outpoint must be 36 bytes") - let txid = rawBytes.prefix(32) - let voutBytes = rawBytes.suffix(4) - let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in - var value: UInt32 = 0 - withUnsafeMutableBytes(of: &value) { dst in - dst.copyBytes(from: raw.prefix(MemoryLayout.size)) - } - return UInt32(littleEndian: value) - } - let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() - return "\(txidHex):\(vout)" - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift index 676d7ebf599..19b5f55d181 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift @@ -194,16 +194,10 @@ public final class PersistentAssetLock { /// mutates the checksum of every registered schema version that /// references it. The model LIST being unchanged is irrelevant. /// - /// Left unaddressed, a store written by the V2 binary would match no - /// schema in `DashMigrationPlan.schemas` and - /// `ModelContainer(for:migrationPlan:configurations:)` would fail to - /// open it with Cocoa error 134504 ("Cannot use staged migration with - /// an unknown model version"). So V1 and V2 now reference a frozen - /// copy of this model (`DashSchemaV1.PersistentAssetLock`, generated - /// under `FrozenSchemas/`), this property is what schema - /// `DashSchemaV3` adds, and a lightweight V2 -> V3 stage carries - /// existing stores across. Do the same for the next property added - /// here. + /// V1 retains its frozen copy without this property. The collapsed + /// V2 adds the column along with the other unreleased model changes; + /// migration backfills NULL. After publication, freeze its exact graph + /// before introducing a subsequent version for further shape changes. public var recipientIsExternal: Bool? /// Record timestamps. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 7daaabbe52d..0a981345dfc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -6,38 +6,9 @@ import XCTest @testable import SwiftDashSDK -/// Migration coverage from two directions: source stores built in this -/// process from each registered version, and stores that OLDER BUILDS -/// actually wrote. -/// -/// The fixture stores under `Fixtures/SchemaStores/` were written by the -/// builds that shipped each version: `dash-v1` to `dash-v3` by a build of -/// the persistence sources as of commit 5f58417079 — the last state before -/// V4, the state the frozen copies under `FrozenSchemas/` are generated -/// from — through that build's own `DashSchemaV1` / `DashSchemaV2` / -/// `DashSchemaV3`, and `dash-v4` by the build that registered V4, through -/// `DashModelContainer.create`. They pin the frozen copies as the pre-V4 -/// build defined them, not what the original V1 release wrote (see the -/// `DashSchemaV1` doc for why those stores are expected to fail open and -/// be rebuilt). Each carries a wallet, an account, a core address, two -/// transactions, a TXO linked to both, a pending input, an identity, a -/// keyword, an asset lock and (from V2) a tracked masternode — enough to -/// exercise every relationship in the wallet graph; the rows are the ones -/// `testWriteTheLiveSchemaFixtureStore` writes. -/// -/// The live version has a fixture too, so a change to a live model's -/// shape fails the hash test against that version's own store — the -/// failure a store in the field would otherwise report as Cocoa error -/// 134504. Changing the live shape before it ships is legitimate; doing -/// so means regenerating the live fixture on purpose, with -/// `testWriteTheLiveSchemaFixtureStore`, in the same change. -/// -/// A source store written in this process by `Schema(versionedSchema:)` -/// cannot replace them: SwiftData binds an entity name to the first Swift -/// type that claims it, so such a store carries whatever shape the process -/// had already bound, and a frozen version whose entities had silently -/// rebound to the live shape would round-trip itself and pass vacuously. -/// Only a store from a build that knew nothing of the live shape can tell. +/// The accepted V1 baseline remains byte-for-byte unchanged. Actual App Store +/// releases captured by the pipeline are additionally checked by +/// DashReleasedSchemaTests. A development version is not a released version. final class DashModelMigrationTests: XCTestCase { /// SwiftData binds an entity name to the first Swift type that claims it /// in the process, so whether the live schema is built before or after @@ -60,27 +31,9 @@ final class DashModelMigrationTests: XCTestCase { Fixture( name: "dash-v1", version: DashSchemaV1.self, hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), - Fixture( - name: "dash-v2", version: DashSchemaV2.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: nil), - Fixture( - name: "dash-v3", version: DashSchemaV3.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: true), - Fixture( - name: "dash-v4", version: DashSchemaV4.self, - hasTrackedMasternode: true, assetLockRecipientIsExternal: true), ] - /// Every schema version that has ever shipped, oldest first, as - /// `major.minor.patch`. APPEND-ONLY: a version that shipped wrote stores - /// that exist in the field, so it can never be removed from, reordered - /// in, or replaced in the migration plan, and the plan is checked - /// against this list rather than the other way round. Adding a version - /// to the plan is shipping it: append it here in the same change and - /// give it a fixture store in `fixtures`, written by that build with - /// `testWriteTheLiveSchemaFixtureStore`. Every entry has a fixture, - /// the live one included. - private static let shippedVersions = ["1.0.0", "2.0.0", "3.0.0", "4.0.0"] + private static let shippedVersions = ["1.0.0"] private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) @@ -115,7 +68,7 @@ final class DashModelMigrationTests: XCTestCase { return (checksum, hashes) } - /// Every fixture opens through `DashModelContainer.create`'s exact + /// The baseline fixture opens through `DashModelContainer.create`'s exact /// order — live schema built first, then the migration plan — and its /// rows come back through the live types with the relationships intact /// and the new columns at their migration defaults. @@ -210,56 +163,15 @@ final class DashModelMigrationTests: XCTestCase { } } - /// Each frozen version, built after the live schema (the order - /// `DashModelContainer.create` uses, established process-wide in - /// `setUp`), still hashes every entity exactly as the build that shipped - /// it did. A partial freeze cannot give this: a frozen wallet reached - /// from a live `PersistentAccount.wallet` is rebound to the live - /// wallet's shape the moment the live schema is built first, and the - /// released checksum moves with it. - /// - /// This test is THE authority on whether a freeze is complete in every - /// respect the entity hash covers: stored properties, their types, - /// optionality and defaults, relationships and their inverses, and - /// `#Unique` constraints. The generator's `--check` - /// (`scripts/freeze_schema_models.py`) only proves the committed frozen - /// files are the generator's byte-for-byte output; it does not, and - /// must not try to, decide whether the `FREEZES` table covers every - /// relationship target and stored value type. A static scan of Swift - /// source cannot: it misses whatever syntax it does not understand, and - /// it flags references SwiftData does not hash at all (a struct stored - /// directly on a model is part of the entity hash; an array of structs - /// nested inside it is not), so it fails silently in both directions. - /// Only building the schema and reading the hash SwiftData computes, - /// against a store a shipping build wrote, answers the question, and - /// that is what this does: an omitted relationship target fails here as - /// soon as the live target has changed shape, an omitted stored value - /// type fails here on the change that would have broken the store, and - /// a version registering the wrong entity set fails on membership. - /// - /// What the hash does NOT cover is `#Index`: Core Data leaves indexes - /// out of entity version hashes, so an index that drifted in a frozen - /// copy, or one a migration never created, passes here. That is what - /// `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` is for. - /// - /// Its reach is exactly the fixtures: it guards a version only once a - /// store written by a build that shipped that version is committed - /// under `Fixtures/SchemaStores/` and listed in `fixtures`. So the first - /// thing checked is that `fixtures` lists every version in - /// `shippedVersions`, the live one included, once each: cutting a new - /// schema version fails this test until its fixture is committed, and - /// changing a live model's shape fails it against the live fixture - /// until that fixture is deliberately rewritten. The expectation comes - /// from the append-only list, not from the migration plan, so a plan - /// that dropped a version cannot shrink it - /// (`testShippedSchemaVersionsStayInTheMigrationPlan`). + /// Keep the accepted V1 hashes stable even after the live graph has been + /// built. A source-store round trip alone can miss accidentally live + /// relationships and inline value types; compare the existing fixture. + /// Captured App Store versions are covered by DashReleasedSchemaTests. func testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped() throws { XCTAssertEqual( Self.fixtures.map { Self.describe($0.version.versionIdentifier) }, Self.shippedVersions, - "every shipped schema version, the live one included, needs a fixture store " - + "written by the build that shipped it, listed once in `fixtures`; without " - + "one its shape is unguarded") + "the accepted baseline must retain its existing fixture") for fixture in Self.fixtures { let (directory, url) = try copyFixture(fixture) @@ -290,29 +202,13 @@ final class DashModelMigrationTests: XCTestCase { "\(version.major).\(version.minor).\(version.patch)" } - /// The migration plan must list exactly the versions that ever shipped, - /// in the order they shipped, with nothing removed, reordered or - /// replaced: a store written by any of them is still in the field and - /// must be recognised. The expectation is the append-only - /// `shippedVersions`, never the plan itself, so editing the plan cannot - /// move the goalposts; a version can only enter the plan by being - /// appended to that list in the same change. - /// - /// Versions are compared by identifier, not by enum: two enums both - /// declaring `4.0.0` are indistinguishable here, so the identifiers in - /// the list must be unique, and whether the enum behind an identifier - /// still has the shape that shipped is decided by that version's - /// fixture in the hash test. - func testShippedSchemaVersionsStayInTheMigrationPlan() { - XCTAssertEqual( - Set(Self.shippedVersions).count, Self.shippedVersions.count, - "a version identifier can ship once") + func testAcceptedBaselineRemainsInTheMigrationPlan() { XCTAssertEqual( - DashMigrationPlan.schemas.map { Self.describe($0.versionIdentifier) }, - Self.shippedVersions, - "the migration plan must list exactly the shipped versions, oldest first; a new " - + "version is appended to `shippedVersions` in the same change, and nothing " - + "that shipped is ever removed, reordered or replaced") + DashMigrationPlan.schemas.prefix(Self.shippedVersions.count).map { + Self.describe($0.versionIdentifier) + }, Self.shippedVersions) + let versions = DashMigrationPlan.schemas.map { Self.describe($0.versionIdentifier) } + XCTAssertEqual(Set(versions).count, versions.count) } /// The schema the app opens stores with must be the last version of @@ -329,101 +225,6 @@ final class DashModelMigrationTests: XCTestCase { "DashModelContainer.schema must be built from the migration plan's last version") } - /// Writes the live version's fixture store — the rows every fixture - /// carries, through `DashModelContainer.create`, so the file is what - /// this build ships. Skipped unless `DASH_SCHEMA_FIXTURE_OUTPUT` names a - /// directory to write into; run it on purpose when the live shape - /// changes before shipping, or when a new version is cut: - /// - /// DASH_SCHEMA_FIXTURE_OUTPUT=/some/dir swift test \ - /// --filter DashModelMigrationTests/testWriteTheLiveSchemaFixtureStore - /// - /// then move `dash-vN.store` into `Fixtures/SchemaStores/`. A retired - /// version's fixture is never rewritten: only the build that shipped it - /// could write it. - @MainActor - func testWriteTheLiveSchemaFixtureStore() throws { - guard let output = ProcessInfo.processInfo.environment["DASH_SCHEMA_FIXTURE_OUTPUT"] - else { - throw XCTSkip("set DASH_SCHEMA_FIXTURE_OUTPUT to write the live schema's fixture") - } - let version = try XCTUnwrap(DashMigrationPlan.schemas.last).versionIdentifier - let url = URL(fileURLWithPath: output, isDirectory: true) - .appendingPathComponent("dash-v\(version.major).store") - // Stop before opening: creating a container over an existing store rewrites its - // checksum and leaves WAL sidecars behind, so a second run would silently - // replace the committed fixture rather than refusing to. - guard !FileManager.default.fileExists(atPath: url.path) else { - XCTFail("\(url.path) already exists; delete it to rewrite the fixture") - return - } - - var container: ModelContainer? = try DashModelContainer.create(url: url) - let context = try XCTUnwrap(container?.mainContext) - let wallet = PersistentWallet( - walletId: Self.fixtureWalletId, network: .testnet, name: "fixture wallet", - syncedHeight: 120) - context.insert(wallet) - let account = PersistentAccount( - wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") - context.insert(account) - let address = PersistentCoreAddress( - address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") - address.account = account - context.insert(address) - let funding = PersistentTransaction( - txid: Self.fixtureFundingTxid, transactionData: Data([3, 0]), context: 2, - blockHeight: 100) - let spend = PersistentTransaction( - txid: Self.fixtureSpendTxid, transactionData: Data([3, 0]), context: 2, - blockHeight: 110) - context.insert(funding) - context.insert(spend) - account.involvedTransactions = [funding, spend] - let txo = PersistentTxo( - transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", - height: 100) - txo.walletId = Self.fixtureWalletId - txo.isSpent = true - txo.spendingTransaction = spend - txo.coreAddress = address - txo.account = account - context.insert(txo) - context.insert(PersistentPendingInput( - outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, - spendingTxid: Self.fixtureSpendTxid, spendingTransaction: spend, - walletId: Self.fixtureWalletId)) - let identity = PersistentIdentity( - identityId: Self.fixtureIdentityId, balance: 5, network: .testnet) - identity.wallet = wallet - context.insert(identity) - context.insert(PersistentKeyword(keyword: "preserved", contractId: "contract")) - let lock = PersistentAssetLock( - outPointHex: String(repeating: "ab", count: 32) + ":0", - walletId: Self.fixtureWalletId, transactionBytes: Data([1, 2, 3]), - fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) - lock.recipientIsExternal = true - context.insert(lock) - context.insert(PersistentTrackedMasternode( - networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), - label: "fixture", addedAt: 1, snapshotJSON: "{}")) - try context.save() - container = nil - - // A fixture has to be one self-contained file that opens read-only - // from any directory, so the write-ahead log is folded back in and - // the store left in rollback-journal mode, which also removes the - // -wal and -shm sidecars. - var database: OpaquePointer? - XCTAssertEqual( - sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil), SQLITE_OK) - XCTAssertEqual(sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil), SQLITE_OK) - sqlite3_close(database) - XCTAssertFalse( - FileManager.default.fileExists(atPath: url.path + "-wal"), - "the store still has a write-ahead log") - } - /// The SQLite indexes of a store, one line per index: table, name and /// the statement that created it. Auto-indexes SQLite makes for its /// own constraints have no statement and are listed as such. @@ -515,383 +316,10 @@ final class DashModelMigrationTests: XCTestCase { } } - @MainActor - func testV1StoreMigratesToV2AndAcceptsTrackedMasternodes() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let v1Schema = Schema(versionedSchema: DashSchemaV1.self) - let v1Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v1Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v1Container: ModelContainer? = try ModelContainer( - for: v1Schema, - configurations: [v1Configuration]) - // V1 registers the frozen graph (see `FrozenSchemas/`), - // so a row written into a V1 container is that type — inserting the - // live one would materialise as the frozen entity and then fail its - // cast on read. - v1Container?.mainContext.insert(DashSchemaV1.PersistentKeyword( - keyword: "preserved", - contractId: "contract")) - try v1Container?.mainContext.save() - v1Container = nil - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - - // V2 registers the same frozen copy, so the read side is frozen too. - let keywords = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(keywords.map(\.keyword), ["preserved"]) - - // V2 registers the frozen `PersistentTrackedMasternode`, so the row - // written into a V2 container is that type too. - migrated.mainContext.insert(DashSchemaV2.PersistentTrackedMasternode( - networkRaw: Network.testnet.rawValue, - proTxHash: Data(repeating: 7, count: 32), - label: "new in V2", - addedAt: 1, - snapshotJSON: "{}")) - try migrated.mainContext.save() - XCTAssertEqual( - try migrated.mainContext.fetchCount( - FetchDescriptor()), - 1) - } - - /// The stage this change adds: a V3 store must migrate to V4 and read - /// back with the sweep columns backfilled to their "nothing swept yet" - /// values. V3 registers the frozen graph, so the row goes in as the - /// frozen type and comes out as the live one — which is the whole point - /// of the freeze: the same entity, one property wider. A pending-input - /// row rides along so the tombstone index V4 adds is exercised by the - /// migration too. - @MainActor - func testV3StoreMigratesToV4AndBackfillsTheSweepColumns() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let walletId = Data(repeating: 0x5A, count: 32) - - let v3Schema = Schema(versionedSchema: DashSchemaV3.self) - let v3Configuration = ModelConfiguration( - "DashSweepMigrationTest", - schema: v3Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v3Container: ModelContainer? = try ModelContainer( - for: v3Schema, - configurations: [v3Configuration]) - v3Container?.mainContext.insert(DashSchemaV1.PersistentWallet( - walletId: walletId, - network: .testnet)) - v3Container?.mainContext.insert(DashSchemaV1.PersistentPendingInput( - outpoint: Data(repeating: 0x11, count: 36), - inputIndex: 0, - spendingTxid: Data(repeating: 0x22, count: 32), - spendingTransaction: nil, - walletId: walletId)) - // A transaction with one spent output: the two other widened - // models, so the migration is exercised on every column V4 adds. - let v3Funding = DashSchemaV1.PersistentTransaction( - txid: Data(repeating: 0x33, count: 32), - transactionData: Data([0x03, 0x00]), - context: 2, - blockHeight: 100) - v3Container?.mainContext.insert(v3Funding) - let v3Coin = DashSchemaV1.PersistentTxo( - transaction: v3Funding, - vout: 0, - amount: 1_000, - address: "yV3Coin", - height: 100) - v3Coin.walletId = walletId - v3Coin.isSpent = true - v3Container?.mainContext.insert(v3Coin) - try v3Container?.mainContext.save() - v3Container = nil - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashSweepMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v4Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v4Configuration]) - - let wallets = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(wallets.count, 1, "the V3 row must survive the migration") - XCTAssertNil( - wallets.first?.lastAppliedChainLockHeight, - "a wallet migrated from V3 has no chainlock boundary yet, so no " - + "tombstone it later takes can be collected on a fabricated one") - let pending = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(pending.count, 1, "the V3 pending row must survive the migration") - XCTAssertEqual(pending.first?.isSweptTombstone, false, "backfilled as an ordinary claim") - XCTAssertNil(pending.first?.winnerMinedHeight, "and unstamped") - let coins = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(coins.count, 1, "the V3 TXO row must survive the migration") - XCTAssertEqual(coins.first?.isSpent, true, "its spent flag is carried as stored") - XCTAssertNil( - coins.first?.supersededByTxid, - "a coin migrated from V3 was never held by a sweep — the stamp backfills to nil, " - + "so the release and re-delivery rules see an ordinary spent coin") - let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(transactions.map(\.context), [2], "the V3 transaction row survives unchanged") - } - - /// The whole chain from the oldest registered version, on the models this - /// change actually widens: a V1 store carrying a wallet, a transaction - /// and a coin must arrive at V4 with every row intact and the V4 columns - /// at their backfill values. V1 and V2 register the frozen graph, - /// so the rows go in as frozen types and come out live — the property - /// the freeze exists to guarantee, pinned here where it matters most. - @MainActor - func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let walletId = Data(repeating: 0x1A, count: 32) - let txid = Data(repeating: 0x1B, count: 32) - - let v1Schema = Schema(versionedSchema: DashSchemaV1.self) - let v1Configuration = ModelConfiguration( - "DashChainMigrationTest", - schema: v1Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v1Container: ModelContainer? = try ModelContainer( - for: v1Schema, - configurations: [v1Configuration]) - v1Container?.mainContext.insert(DashSchemaV1.PersistentWallet( - walletId: walletId, - network: .testnet)) - let v1Funding = DashSchemaV1.PersistentTransaction( - txid: txid, - transactionData: Data([0x03, 0x00]), - context: 3, - blockHeight: 50, - netAmount: 2_000) - v1Container?.mainContext.insert(v1Funding) - let v1Coin = DashSchemaV1.PersistentTxo( - transaction: v1Funding, - vout: 1, - amount: 2_000, - address: "yV1Coin", - height: 50) - v1Coin.walletId = walletId - v1Container?.mainContext.insert(v1Coin) - try v1Container?.mainContext.save() - v1Container = nil - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashChainMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v4Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v4Configuration]) - - let wallets = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(wallets.map(\.walletId), [walletId]) - XCTAssertNil(wallets.first?.lastAppliedChainLockHeight) - let transactions = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(transactions.map(\.txid), [txid]) - XCTAssertEqual(transactions.first?.context, 3) - XCTAssertEqual(transactions.first?.netAmount, 2_000) - let coins = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(coins.count, 1) - XCTAssertEqual(coins.first?.vout, 1) - XCTAssertEqual(coins.first?.amount, 2_000) - XCTAssertEqual(coins.first?.walletId, walletId) - XCTAssertEqual(coins.first?.isSpent, false) - XCTAssertNil(coins.first?.supersededByTxid) - XCTAssertEqual( - coins.first?.transaction?.txid, txid, - "the coin's relationship to its funding transaction survives three stages") - } - - /// What makes the V3 -> V4 stage lightweight: the two versions name the - /// same entity set, and V4 only widens three of them. Also pins that - /// `PersistentTransaction` is NOT one of the three — a swept row is - /// deleted outright, so the transaction entity carries no sweep marker, - /// and one that came back would silently change V4's checksum. - func testV3AndV4NameTheSameEntitySet() throws { - let v3 = Schema(versionedSchema: DashSchemaV3.self) - let v4 = Schema(versionedSchema: DashSchemaV4.self) - XCTAssertEqual( - v3.entities.map(\.name).sorted(), - v4.entities.map(\.name).sorted()) - - let transaction = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTransaction" }) - let frozenTransaction = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTransaction" }) - XCTAssertEqual( - transaction.attributesByName.keys.sorted(), - frozenTransaction.attributesByName.keys.sorted(), - "V4 adds no column to PersistentTransaction") - let txo = try XCTUnwrap(v4.entities.first { $0.name == "PersistentTxo" }) - XCTAssertNotNil(txo.attributesByName["supersededByTxid"]) - let pendingInput = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPendingInput" }) - XCTAssertNotNil(pendingInput.attributesByName["isSweptTombstone"]) - XCTAssertNotNil(pendingInput.attributesByName["winnerMinedHeight"]) - let wallet = try XCTUnwrap(v4.entities.first { $0.name == "PersistentWallet" }) - XCTAssertNotNil(wallet.attributesByName["lastAppliedChainLockHeight"]) - - // And V3's frozen copies do not carry them. - let frozenTxo = try XCTUnwrap(v3.entities.first { $0.name == "PersistentTxo" }) - XCTAssertNil(frozenTxo.attributesByName["supersededByTxid"]) - } - - /// Guards the freeze itself: `DashSchemaV1.PersistentAssetLock` only - /// keeps V1/V2 stores openable if SwiftData names its entity - /// "PersistentAssetLock" — i.e. from the UNQUALIFIED type name. If a - /// future SwiftData release qualified nested types instead, the frozen - /// copy would silently register a *different* entity and the V2 -> V3 - /// stage would become a drop+create rather than an add-column, so this - /// has to fail loudly rather than in the field. - func testFrozenAssetLockKeepsTheLiveEntityName() throws { - for schema in [ - Schema(versionedSchema: DashSchemaV1.self), - Schema(versionedSchema: DashSchemaV2.self), - Schema(versionedSchema: DashSchemaV3.self), - Schema(versionedSchema: DashSchemaV4.self) - ] { - let names = schema.entities.map(\.name) - XCTAssertTrue( - names.contains("PersistentAssetLock"), - "expected an entity named PersistentAssetLock, got \(names.sorted())") - } - - // V2 and V3 differ ONLY in that one entity's shape, never in which - // entities exist — that is what makes the stage lightweight. - XCTAssertEqual( - Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name).sorted(), - Schema(versionedSchema: DashSchemaV3.self).entities.map(\.name).sorted()) - - // V1 -> V2 remains exactly "add PersistentTrackedMasternode". + func testV2AddsTrackedMasternodesToTheBaselineEntitySet() { XCTAssertEqual( Set(Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name)) .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), ["PersistentTrackedMasternode"]) } - - /// The regression this whole freeze exists for: a store written by the - /// schema-V2 definition of `PersistentAssetLock` (no `recipientIsExternal`) - /// must still open once the live model has grown that property. - /// - /// The source store is created from `DashSchemaV2`, which references the - /// frozen `DashSchemaV1.PersistentAssetLock` — not the live type — so - /// this exercises the real cross-version path rather than trivially - /// round-tripping today's model. - @MainActor - func testV2AssetLockStoreMigratesToV3AndBackfillsRecipientIsExternal() throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("dash.store") - - let outPointHex = String(repeating: "ab", count: 32) + ":0" - let walletId = Data(repeating: 3, count: 32) - - let v2Schema = Schema(versionedSchema: DashSchemaV2.self) - let v2Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v2Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v2Container: ModelContainer? = try ModelContainer( - for: v2Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v2Configuration]) - let legacyRow = DashSchemaV1.PersistentAssetLock( - outPointHex: outPointHex, - walletId: walletId, - transactionBytes: Data([1, 2, 3]), - fundingTypeRaw: 4, - identityIndexRaw: -1, - accountIndexRaw: 0, - amountDuffs: 100_000, - statusRaw: 4) - legacyRow.recipientPlatformAddressHash = Data(repeating: 9, count: 20) - legacyRow.recipientPlatformAddressType = 0 - v2Container?.mainContext.insert(legacyRow) - try v2Container?.mainContext.save() - v2Container = nil - - // Reopen exactly the way `DashModelContainer.create` does. - let v3Schema = Schema(versionedSchema: DashSchemaV3.self) - let v3Configuration = ModelConfiguration( - "DashAssetLockMigrationTest", - schema: v3Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - let migrated = try ModelContainer( - for: v3Schema, - migrationPlan: DashMigrationPlan.self, - configurations: [v3Configuration]) - - // V3 registers the frozen `DashSchemaV3.PersistentAssetLock`, the - // shape that gained the column, so the read side is that type. - let locks = try migrated.mainContext.fetch( - FetchDescriptor()) - XCTAssertEqual(locks.count, 1) - let lock = try XCTUnwrap(locks.first) - XCTAssertEqual(lock.outPointHex, outPointHex) - XCTAssertEqual(lock.walletId, walletId) - XCTAssertEqual(lock.recipientPlatformAddressHash, Data(repeating: 9, count: 20)) - XCTAssertEqual(lock.recipientPlatformAddressType, 0) - // Backfilled NULL — the documented "treat as own" signal. - XCTAssertNil(lock.recipientIsExternal) - - // And the new column is writable on the migrated row. - lock.recipientIsExternal = true - try migrated.mainContext.save() - XCTAssertEqual( - try migrated.mainContext.fetch(FetchDescriptor()) - .first?.recipientIsExternal, - true) - } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift new file mode 100644 index 00000000000..192033ab627 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift @@ -0,0 +1,8 @@ +// Generated by scripts/freeze_schema_models.py. Do not edit. +@testable import SwiftDashSDK + +enum DashReleasedSchemaRegistry { + static let fixtures: [DashReleasedSchemaFixture] = [ + + ] +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift new file mode 100644 index 00000000000..c9c159ad28a --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -0,0 +1,109 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +struct DashReleasedSchemaFixture: Sendable { + let version: any VersionedSchema.Type + let resourceName: String +} + +/// Publication adds archival snapshots, never additional runtime stages. These +/// checks deliberately fail if development changed a published version in place: +/// move the old version onto its snapshot, add a live version and a migration. +final class DashReleasedSchemaTests: XCTestCase { + override class func setUp() { + super.setUp() + _ = DashModelContainer.schema + } + + private func source(_ fixture: DashReleasedSchemaFixture) throws -> URL { + try XCTUnwrap(Bundle.module.url( + forResource: fixture.resourceName, withExtension: "store", + subdirectory: "Fixtures/SchemaStores/releases")) + } + + @MainActor + func testPublishedSnapshotsAndRuntimeVersionsMatchCapturedStores() throws { + for fixture in DashReleasedSchemaRegistry.fixtures { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let version = fixture.version.versionIdentifier + let expected = try DashSchemaFixtureSupport.describeStore(at: source(fixture), version: version) + let registered = try XCTUnwrap(DashMigrationPlan.schemas.first { + $0.versionIdentifier == version + }, "A published version is missing from the runtime migration plan") + for (name, type) in [("snapshot", fixture.version), ("runtime", registered)] { + let url = directory.appendingPathComponent("\(name).store") + try autoreleasepool { + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(name, schema: schema, url: url, cloudKitDatabase: .none) + ]) + } + XCTAssertEqual( + try DashSchemaFixtureSupport.describeStore(at: url, version: version), expected, + "\(name) changed published \(expected.schema_version); retire it onto its snapshot and introduce a new live version") + } + } + } + + @MainActor + func testPublishedStoresMigrateAndRemainWritableThroughLiveTypes() throws { + for fixture in DashReleasedSchemaRegistry.fixtures { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("migrated.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let wallet = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(wallet.walletId, Data(repeating: 0x31, count: 32)) + XCTAssertEqual(wallet.name, "fixture wallet") + XCTAssertEqual(wallet.accounts.count, 1) + XCTAssertEqual(wallet.accounts.first?.coreAddresses.first?.address, "yFixtureAddress") + let txo = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(txo.amount, 1_000) + XCTAssertEqual(txo.transaction?.txid, Data(repeating: 0x34, count: 32)) + XCTAssertEqual(txo.spendingTransaction?.txid, Data(repeating: 0x32, count: 32)) + XCTAssertEqual(txo.account?.wallet.walletId, wallet.walletId) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.keyword, "preserved") + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.balance, 5) + let lock = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(lock.amountDuffs, 100_000) + XCTAssertEqual(lock.recipientIsExternal, true) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 2) + wallet.name = "migrated and writable" + try context.save() + XCTAssertEqual(try context.fetch(FetchDescriptor()).first?.name, "migrated and writable") + let fresh = directory.appendingPathComponent("fresh.store") + _ = try DashModelContainer.create(url: fresh) + XCTAssertTrue(Set(try DashSchemaFixtureSupport.indexes(at: fresh)) + .isSubset(of: Set(try DashSchemaFixtureSupport.indexes(at: url)))) + } + } + + @MainActor + func testRuntimePlanHasNoDuplicateModelChecksums() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + var checksums = Set() + for type in DashMigrationPlan.schemas { + let version = type.versionIdentifier + let url = directory.appendingPathComponent(DashSchemaFixtureSupport.version(version) + ".store") + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + let metadata = try DashSchemaFixtureSupport.describeStore(at: url, version: version) + XCTAssertTrue(checksums.insert(metadata.model_checksum).inserted, + "A snapshot with unchanged shape must not become an additional runtime migration stage") + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift new file mode 100644 index 00000000000..dd0b852244e --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift @@ -0,0 +1,135 @@ +import CoreData +import Foundation +import SQLite3 +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Shared synthetic release evidence; never reads a user's wallet or keychain. +enum DashSchemaFixtureSupport { + struct Description: Codable, Equatable { + let schema_version: String + let model_checksum: String + let entity_hashes: [String: String] + let indexes: [String] + } + + static func version(_ version: Schema.Version) -> String { + "\(version.major).\(version.minor).\(version.patch)" + } + + static func describeStore(at url: URL, version: Schema.Version) throws -> Description { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + let identifiers = try XCTUnwrap(metadata["NSStoreModelVersionIdentifiers"] as? [String]) + guard identifiers == [self.version(version)] else { + throw NSError(domain: "DashSchemaFixtureVersionMismatch", code: 1) + } + let checksum = try XCTUnwrap(metadata["NSStoreModelVersionChecksumKey"] as? String) + let hashes = try XCTUnwrap(metadata["NSStoreModelVersionHashes"] as? [String: Data]) + return Description( + schema_version: self.version(version), model_checksum: checksum, + entity_hashes: hashes.mapValues { data in data.map { String(format: "%02x", $0) }.joined() }, + indexes: try indexes(at: url)) + } + + static func indexes(at url: URL) throws -> [String] { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "DashSchemaFixture", code: 1) + } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + let query = "SELECT tbl_name, name, sql FROM sqlite_master WHERE type = 'index'" + guard sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK else { + throw NSError(domain: "DashSchemaFixture", code: 2) + } + defer { sqlite3_finalize(statement) } + var result: [String] = [] + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + let table = String(cString: sqlite3_column_text(statement, 0)) + let name = String(cString: sqlite3_column_text(statement, 1)) + let sql = sqlite3_column_text(statement, 2).map { String(cString: $0) } ?? "(auto)" + result.append("\(table) \(name): \(sql)") + step = sqlite3_step(statement) + } + guard step == SQLITE_DONE else { throw NSError(domain: "DashSchemaFixture", code: Int(step)) } + return result.sorted() + } + + @MainActor + static func writeLiveStore(at url: URL) throws { + guard !FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "DashSchemaFixtureAlreadyExists", code: 1) + } + try autoreleasepool { try populateStore(at: url) } + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "DashSchemaFixture", code: 3) + } + defer { sqlite3_close(database) } + guard sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK, + sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil) == SQLITE_OK + else { throw NSError(domain: "DashSchemaFixture", code: 4) } + guard !FileManager.default.fileExists(atPath: url.path + "-wal"), + !FileManager.default.fileExists(atPath: url.path + "-shm") + else { throw NSError(domain: "DashSchemaFixtureSidecars", code: 1) } + } + + @MainActor + private static func populateStore(at url: URL) throws { + let container = try DashModelContainer.create(url: url) + let context = container.mainContext + let wallet = PersistentWallet( + walletId: Data(repeating: 0x31, count: 32), network: .testnet, name: "fixture wallet", + syncedHeight: 120) + context.insert(wallet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "standard") + context.insert(account) + let address = PersistentCoreAddress( + address: "yFixtureAddress", poolTypeTag: 0, addressIndex: 0, derivationPath: "m/0") + address.account = account + context.insert(address) + let funding = PersistentTransaction( + txid: Data(repeating: 0x34, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 100) + let spend = PersistentTransaction( + txid: Data(repeating: 0x32, count: 32), transactionData: Data([3, 0]), context: 2, + blockHeight: 110) + context.insert(funding) + context.insert(spend) + account.involvedTransactions = [funding, spend] + let txo = PersistentTxo( + transaction: funding, vout: 0, amount: 1_000, address: "yFixtureAddress", + height: 100) + txo.walletId = Data(repeating: 0x31, count: 32) + txo.isSpent = true + txo.spendingTransaction = spend + txo.coreAddress = address + txo.account = account + context.insert(txo) + context.insert(PersistentPendingInput( + outpoint: Data(repeating: 0x11, count: 36), inputIndex: 0, + spendingTxid: Data(repeating: 0x32, count: 32), spendingTransaction: spend, + walletId: Data(repeating: 0x31, count: 32))) + let identity = PersistentIdentity( + identityId: Data(repeating: 0x35, count: 32), balance: 5, network: .testnet) + identity.wallet = wallet + context.insert(identity) + context.insert(PersistentKeyword(keyword: "preserved", contractId: "contract")) + let lock = PersistentAssetLock( + outPointHex: String(repeating: "ab", count: 32) + ":0", + walletId: Data(repeating: 0x31, count: 32), transactionBytes: Data([1, 2, 3]), + fundingTypeRaw: 4, identityIndexRaw: -1, amountDuffs: 100_000, statusRaw: 4) + lock.recipientIsExternal = true + context.insert(lock) + context.insert(PersistentTrackedMasternode( + networkRaw: Network.testnet.rawValue, proTxHash: Data(repeating: 7, count: 32), + label: "fixture", addedAt: 1, snapshotJSON: "{}")) + try context.save() + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift new file mode 100644 index 00000000000..67a0a842052 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaReleaseCaptureTests.swift @@ -0,0 +1,30 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Run on an iOS simulator built from the same pinned sources as the archive. +/// The workflow exports these attachments before uploading that archive. +final class DashSchemaReleaseCaptureTests: XCTestCase { + @MainActor + func testCaptureReleaseSchema() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = directory.appendingPathComponent("fixture.store") + try DashSchemaFixtureSupport.writeLiveStore(at: store) + let description = try DashSchemaFixtureSupport.describeStore( + at: store, version: DashModelContainer.schema.version) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let metadata = directory.appendingPathComponent("schema.json") + try encoder.encode(description).write(to: metadata) + for url in [metadata, store] { + let attachment = XCTAttachment(contentsOfFile: url) + attachment.name = url.lastPathComponent + attachment.lifetime = .keepAlways + add(attachment) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store deleted file mode 100644 index 6ce7443421e3e03b51063f8de4c18fa732fc6534..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 659456 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ȈlXo0uX=z1Rwwb2tWV=5P$## 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 W 1 else ''} failed: " + f"{result.stderr.decode(errors='replace')[-2000:]}") + return result.stdout + + +def git(directory, *args, env=None): + return run(directory, "git", *args, env=env).decode().strip() + + +def require_string(value, pattern, label): + if not isinstance(value, str) or not pattern.fullmatch(value): + raise ReleaseError(f"Invalid {label}") + return value + + +def read_blob(directory, commit, path): + entry = git(directory, "ls-tree", commit, "--", path) + if not entry.startswith("100644 blob "): + raise ReleaseError(f"Missing regular data file: {path}") + return run(directory, "git", "show", f"{commit}:{path}") + + +def json_object(data, label): + try: + value = json.loads(data) + except (ValueError, UnicodeError) as error: + raise ReleaseError(f"Invalid JSON: {label}") from error + if not isinstance(value, dict) or value.get("format_version") != 1: + raise ReleaseError(f"Unsupported format: {label}") + return value + + +def verify_data_origin(directory): + origin = git(directory, "remote", "get-url", "origin") + if origin not in (f"https://github.com/{IOS_REPO}", f"https://github.com/{IOS_REPO}.git", + f"git@github.com:{IOS_REPO}.git"): + raise ReleaseError("Release metadata must come from dashpay/dashwallet-ios") + + +def validate_publication(data_repo, data_commit, release_id): + """Return verified publication proof, manifest, and fixture bytes.""" + require_string(data_commit, SHA, "metadata commit") + require_string(release_id, COMPONENT, "Apple release ID") + verify_data_origin(data_repo) + git(data_repo, "merge-base", "--is-ancestor", data_commit, f"origin/{DATA_BRANCH}") + proof = json_object(read_blob(data_repo, data_commit, f"releases/{release_id}.json"), "release proof") + if proof.get("release_id") != release_id: + raise ReleaseError("Release proof ID does not match the requested release") + if proof.get("observed_state") not in ( + "READY_FOR_DISTRIBUTION", "READY_FOR_SALE", "REPLACED_WITH_NEW_VERSION" + ): + raise ReleaseError("The version has not been published in the App Store") + for field in ("app_id", "bundle_id", "app_version", "build_number", "build_id"): + require_string(proof.get(field), COMPONENT, field) + expected_path = (f"builds/{proof['bundle_id']}/{proof['app_version']}/" + f"{proof['build_number']}/manifest.json") + if proof.get("manifest_path") != expected_path: + raise ReleaseError("Release proof does not point to its exact build manifest") + manifest_bytes = read_blob(data_repo, data_commit, expected_path) + require_string(proof.get("manifest_sha256"), DIGEST, "manifest digest") + if hashlib.sha256(manifest_bytes).hexdigest() != proof["manifest_sha256"]: + raise ReleaseError("Build manifest checksum mismatch") + manifest = json_object(manifest_bytes, "build manifest") + for field in ("bundle_id", "app_version", "build_number"): + if manifest.get(field) != proof[field]: + raise ReleaseError(f"Build identity mismatch: {field}") + require_string(manifest.get("platform_sha"), SHA, "Platform commit") + require_string(manifest.get("wallet_sha"), SHA, "wallet commit") + schema = manifest.get("schema") + if not isinstance(schema, dict): + raise ReleaseError("Missing schema descriptor") + require_string(schema.get("schema_version"), VERSION, "schema version") + if not isinstance(schema.get("model_checksum"), str) or not schema["model_checksum"]: + raise ReleaseError("Missing model checksum") + hashes = schema.get("entity_hashes") + if not isinstance(hashes, dict) or not hashes: + raise ReleaseError("Missing entity hashes") + for name, digest in hashes.items(): + require_string(name, COMPONENT, "entity name") + require_string(digest, re.compile(r"(?:[0-9a-f]{2})+"), "entity hash") + indexes = schema.get("indexes") + if not isinstance(indexes, list) or not all(isinstance(x, str) for x in indexes): + raise ReleaseError("Missing SQLite indexes") + if len(set(indexes)) != len(indexes): + raise ReleaseError("Duplicate SQLite index descriptors") + require_string(manifest.get("fixture_sha256"), DIGEST, "fixture digest") + fixture_path = f"stores/{manifest['fixture_sha256']}.store" + if manifest.get("fixture_path") != fixture_path: + raise ReleaseError("Fixture must use its content-addressed store path") + fixture = read_blob(data_repo, data_commit, fixture_path) + if hashlib.sha256(fixture).hexdigest() != manifest["fixture_sha256"]: + raise ReleaseError("Fixture checksum mismatch") + if not fixture.startswith(b"SQLite format 3\x00"): + raise ReleaseError("Fixture is not a SQLite store") + return proof, manifest, fixture + + +def release_entry(proof, manifest, data_commit): + entry = {field: proof[field] for field in + ("app_id", "bundle_id", "app_version", "build_number", "build_id", "manifest_sha256")} + entry.update(schema_version=manifest["schema"]["schema_version"], + platform_sha=manifest["platform_sha"], data_commit=data_commit) + return entry + + +def associate_release(registry, release_id, entry): + """An observation at a newer metadata commit must not rewrite old provenance.""" + releases = registry.setdefault("releases", {}) + previous = releases.get(release_id) + if previous: + expected = dict(entry, data_commit=previous.get("data_commit")) + if previous != expected: + raise ReleaseError("This App Store release already has different provenance") + return False + if entry["schema_version"] not in registry.get("schemas", {}): + raise ReleaseError("Cannot associate a release without its generated snapshot") + releases[release_id] = entry + return True + + +def permitted_change(path): + return (path in (REGISTRY, GENERATED_TEST) + or (path.startswith(f"{SDK}/Sources/SwiftDashSDK/Persistence/FrozenSchemas/") + and path.endswith(".swift")) + or (path.startswith(f"{SDK}/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases/") + and path.endswith(".store"))) + + +class GitHub: + def __init__(self, token, opener=urllib.request.urlopen, sleep=time.sleep): + self.token, self.opener, self.sleep = token, opener, sleep + + def request(self, method, path, payload=None): + request = urllib.request.Request( + f"https://api.github.com/repos/{PLATFORM_REPO}/{path}", method=method, + data=None if payload is None else json.dumps(payload).encode(), + headers={"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json"}) + # Retry reads only. A POST may have succeeded before a connection failed; + # the next workflow run reconciles against existing PRs instead. + for attempt in range(4): + try: + with self.opener(request, timeout=45) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if method == "GET" and error.code in (429, 500, 502, 503, 504) and attempt < 3: + self.sleep(min(30, 2 ** attempt)) + continue + raise ReleaseError(f"GitHub {method} failed with HTTP {error.code}") from error + except urllib.error.URLError as error: + if method == "GET" and attempt < 3: + self.sleep(2 ** attempt) + continue + raise ReleaseError("GitHub request failed; retry the workflow to reconcile its state") from error + + def pull_requests(self, branch): + results = [] + page = 1 + while True: + query = urllib.parse.urlencode({"head": f"dashpay:{branch}", "base": BASE_BRANCH, + "state": "all", "per_page": 100, "page": page}) + rows = self.request("GET", f"pulls?{query}") + results.extend(rows) + if len(rows) < 100: + return results + page += 1 + + +def git_environment(token): + env = os.environ.copy() + # This is process-local and never written to git config or command output. + credential = base64.b64encode(f"x-access-token:{token}".encode()).decode() + env.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0=f"AUTHORIZATION: basic {credential}", GIT_TERMINAL_PROMPT="0") + return env + + +def pr_body(proof, manifest, data_commit): + schema = manifest["schema"]["schema_version"] + return f"""## Issue being fixed or feature implemented +App Store version {proof['app_version']} (build {proof['build_number']}) shipped SwiftData schema {schema}. +Preserve its model definitions and synthetic migration fixture before changing that schema. + +## What was done? +Generated the snapshot from Platform commit `{manifest['platform_sha']}` and associated Apple release `{proof['release_id']}`. +The publication proof and immutable build manifest are recorded in [iOS release metadata](https://github.com/{IOS_REPO}/blob/{data_commit}/releases/{proof['release_id']}.json). +This PR does not change the active runtime schema or invent a migration. If development has moved, reconcile the next schema version and migration before merging. + +## How Has This Been Tested? +Metadata checksums and snapshot regeneration were checked by the freeze worker. Normal Swift SDK CI checks the stored schema, indexes and migrations. + +## Breaking Changes +No runtime schema switch is included. This draft requires human review and a manual merge; no auto-merge is enabled. +""" + + +def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): + proof, manifest, fixture = validate_publication(data_repo, data_commit, release_id) + branch = f"codex/freeze-swift-schema-v{manifest['schema']['schema_version']}" + api = GitHub(token) + pulls = api.pull_requests(branch) + opened = [pr for pr in pulls if pr["state"] == "open"] + if len(opened) > 1: + raise ReleaseError("Multiple open snapshot pull requests need manual reconciliation") + if not opened and pulls and not pulls[0].get("merged_at"): + raise ReleaseError("The snapshot pull request was closed without merging; reopen it to retry") + env = git_environment(token) + with tempfile.TemporaryDirectory(prefix="appstore-schema-") as temporary: + clone = Path(temporary) / "platform" + run(None, "git", "clone", "--shared", "--no-checkout", str(repo), str(clone)) + git(clone, "remote", "set-url", "origin", f"https://github.com/{PLATFORM_REPO}.git") + git(clone, "config", "user.name", "Dash schema release automation") + git(clone, "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") + git(clone, "config", "commit.gpgsign", "false") + git(clone, "fetch", "origin", f"{BASE_BRANCH}:refs/remotes/origin/{BASE_BRANCH}", env=env) + git(clone, "checkout", "--detach", f"origin/{BASE_BRANCH}") + merged_registry = json_object((clone / REGISTRY).read_bytes(), "merged snapshot registry") + if release_id in merged_registry.get("releases", {}): + associate_release(merged_registry, release_id, release_entry(proof, manifest, data_commit)) + print("This release is already present in the merged registry.") + return + remote_branch = git(clone, "ls-remote", "--heads", "origin", branch, env=env) + if remote_branch: + git(clone, "fetch", "origin", f"{branch}:refs/remotes/origin/{branch}", env=env) + git(clone, "checkout", "-b", branch, f"origin/{branch}") + # Merge with no commit so --dry-run never writes a commit. The + # publication commit below includes the merge and generated patch. + git(clone, "merge", "--no-commit", "--no-ff", f"origin/{BASE_BRANCH}") + else: + git(clone, "checkout", "-b", branch, f"origin/{BASE_BRANCH}") + # A released SHA can be absent from current branch history after a + # force-updated development branch. Fetch exactly the proven SHA. + git(clone, "fetch", "origin", manifest["platform_sha"], env=env) + run(clone, sys.executable, GENERATOR, "--check") + before_registry = json_object((clone / REGISTRY).read_bytes(), "snapshot registry") + immutable_files = {path: (clone / path).read_bytes() + for path in git(clone, "ls-files").splitlines() + if permitted_change(path) and path not in (REGISTRY, GENERATED_TEST)} + manifest_file = Path(temporary) / "manifest.json" + fixture_file = Path(temporary) / "fixture.store" + manifest_file.write_text(json.dumps(manifest)) + fixture_file.write_bytes(fixture) + with sqlite3.connect(f"file:{fixture_file}?immutable=1", uri=True) as database: + if database.execute("PRAGMA quick_check").fetchone() != ("ok",): + raise ReleaseError("The release fixture is corrupt or needs a WAL file") + run(clone, sys.executable, GENERATOR, "--release-manifest", str(manifest_file), "--fixture", str(fixture_file)) + if any(not (clone / path).is_file() or (clone / path).read_bytes() != content + for path, content in immutable_files.items()): + raise ReleaseError("Attempted to replace an existing snapshot or fixture") + registry = json_object((clone / REGISTRY).read_bytes(), "snapshot registry") + # The generator may append a schema, but existing snapshots are immutable. + for key in ("schemas", "releases"): + for identifier, value in before_registry.get(key, {}).items(): + if registry.get(key, {}).get(identifier) != value: + raise ReleaseError(f"Attempted to rewrite existing {key} entry: {identifier}") + associate_release(registry, release_id, release_entry(proof, manifest, data_commit)) + (clone / REGISTRY).write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n") + run(clone, sys.executable, GENERATOR, "--check") + # Inspect only unstaged changes: a non-conflicting base merge may have + # staged unrelated source updates, which are preserved in the merge. + changed = set(git(clone, "diff", "--name-only").splitlines()) + changed.update(git(clone, "ls-files", "--others", "--exclude-standard").splitlines()) + if any(not permitted_change(path) for path in changed): + raise ReleaseError("The generator changed files outside the snapshot allowlist") + print(json.dumps({"branch": branch, "release_id": release_id, + "files": sorted(changed), "dry_run": dry_run}, indent=2)) + if dry_run: + return + if changed: + git(clone, "add", "--", *sorted(changed)) + staged = git(clone, "diff", "--cached", "--name-only") + if staged or (clone / ".git/MERGE_HEAD").exists(): + git(clone, "commit", "-m", f"chore(swift-sdk): freeze App Store schema {manifest['schema']['schema_version']}") + # Deliberately no force or force-with-lease. Concurrent human edits + # cause a rejection; the next run starts from the new branch tip. + git(clone, "push", "origin", f"HEAD:refs/heads/{branch}", env=env) + # A previous push may have succeeded while PR creation failed. Even + # when generation is a no-op, recover by creating the missing PR. + if opened: + print(f"Snapshot pull request: {opened[0]['html_url']}") + else: + created = api.request("POST", "pulls", { + "title": f"chore(swift-sdk): freeze App Store schema {manifest['schema']['schema_version']}", + "head": branch, "base": BASE_BRANCH, "draft": True, + "body": pr_body(proof, manifest, data_commit)}) + print(f"Snapshot pull request: {created['html_url']}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-id", required=True) + parser.add_argument("--data-commit", required=True) + parser.add_argument("--data-repo", required=True, type=Path) + parser.add_argument("--repo", default=Path.cwd(), type=Path) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + token = os.environ.get("SCHEMA_RELEASE_TOKEN", "") + if not token: + parser.error("SCHEMA_RELEASE_TOKEN is required for read access, including dry runs") + try: + prepare(args.repo.resolve(), args.data_repo.resolve(), args.release_id, + args.data_commit, token, args.dry_run) + except (ReleaseError, OSError, sqlite3.Error) as error: + print(f"Freeze stopped: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index baf78f0f269..dc2325b9263 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -1,91 +1,40 @@ #!/usr/bin/env python3 -"""Generate the frozen SwiftData model copies for released schema versions. - -A `VersionedSchema` identifies a store by the checksum of the entities it -declares, so a released version may only reference model types whose shape -never changes again. Pointing a released version at a live `@Model` type -means the next property added to that type silently changes the released -checksum: a store written by the previously shipped build then matches no -registered version and fails to open with Cocoa error 134504 ("Cannot use -staged migration with an unknown model version") instead of migrating. - -This script copies each live `@Model` class as it existed at a given commit -into a nested type of the schema enum that version registers -(`extension DashSchemaV1 { final class PersistentX { ... } }`), one file per -model, under `Persistence/FrozenSchemas/`. SwiftData derives the entity name -from the unqualified type name, so `DashSchemaV1.PersistentX` and the live -`PersistentX` describe the same entity, which is what lets a migration stage -map one onto the other. - -The class body and the extensions declared in the model's own file are -copied; doc comments, `public` modifiers and top-level enums are dropped. -Extensions add no stored properties (so they are not part of the entity) -but the class body may call into them. The stored properties, their -optionality and defaults, `@Attribute`, `@Relationship` and `#Unique` are -what the checksum hashes, and they are copied verbatim. `#Index` is copied -verbatim too but is NOT part of the hash (Core Data leaves indexes out of -entity version hashes), which is why index drift needs its own check. - -Value types a model stores inline (Codable structs and raw enums SwiftData -expands into composite attributes, such as `ChangeControlRules` on -`PersistentToken`) are entity-hash inputs too, so they are frozen the same -way, into one nested file per schema, and every frozen model body then -resolves those names to the nested copies. Names are qualified per schema: -a model frozen only under `DashSchemaV2` that mentioned a `DashSchemaV1` -model by bare name in an extension would still bind to the live type. - -Every released version is frozen as a whole graph, never partially: a -relationship binds its destination by entity name, and SwiftData resolves -that name to whichever Swift type claimed it first in the process, so a -frozen model whose relationship pointed at a live type could be hashed with -the live type's current shape. - -`FREEZES` below is the record of what each released version registers and -the commit its shapes are taken from. Rows are append-only: retiring a -version means adding rows for it (normally one row listing every model at -the last commit before the change), adding the new `DashSchemaVN` and a -migration stage, and rerunning this script. Never edit an existing row; a -released checksum cannot move. - -Nothing here checks that the table is COMPLETE. That is deliberate. A -frozen model whose relationship target or stored value type is missing -from the table binds that bare name to the live type, and the released -checksum then moves with the live type's next change; but whether a given -Swift reference feeds the entity hash is decided by SwiftData (a struct -stored directly on a model does, an array of structs nested inside one does -not), and a text scan of Swift source cannot know that, nor keep up with -optionals, generics, extensions, nested types and enum payloads. Every -reference such a scan misses is a silent failure in the field, and every -one it wrongly flags is a false alarm. The authority for -hash-relevant completeness (properties, relationships, `#Unique`) is -`DashModelMigrationTests.testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped`, -which builds each released version after the live schema and compares the -hashes SwiftData computes against a store the shipping build wrote; its -sibling `testFixturesAndMigratedStoresCarryTheIndexesFreshStoresHave` -covers `#Index`, which the hash cannot see, by comparing SQLite indexes. -Do not add static validation here; extend those tests (and their -fixtures) instead. - -Usage, from anywhere inside the repository: - - scripts/freeze_schema_models.py # regenerate every frozen file - scripts/freeze_schema_models.py --check # exit 1 if any file would change - -`--check` is a regeneration check and nothing more: the frozen files are a -pure function of `FREEZES` and the repository history, so a clean check -proves that the committed files are exactly this generator's output, byte -for byte, and that no one edited a frozen copy by hand. It says nothing -about whether the freeze is complete. CI runs it (the -`swift-sdk-frozen-schema` job in `.github/workflows/tests.yml`) on a -full-history checkout, because it needs the commits named in `FREEZES`. - -The generator's own tests, from the repository root: - - python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' +"""Generate immutable SwiftData snapshots from the exact sources that shipped. + +V1's accepted historical FREEZES rows remain unchanged. New App Store releases +are recorded in schema-releases.json, using the full Platform SHA and the model +inventory committed at that SHA. Intermediate TestFlight builds only capture +evidence; they do not add released schema versions. + +Each release snapshot copies the complete model graph and stored value types +into a separate DashSchemaSnapshotVN namespace. It is not an additional runtime +migration stage: DashSchemaVN stays on live model types until a later shape +change moves it onto the snapshot and introduces a new live version/migration. + +--check verifies deterministic generated sources, registry bindings and immutable +fixture digests. It cannot prove hash-relevant graph completeness: the runtime +DashReleasedSchemaTests compares SwiftData's hashes and SQLite indexes against +the captured fixture, after constructing the live schema first. This also +catches inline value types or relationship references accidentally left live. +Do not replace those runtime checks with a static scan of model source. + +Usage: + python3 packages/swift-sdk/scripts/freeze_schema_models.py --check + python3 packages/swift-sdk/scripts/freeze_schema_models.py \ + --release-manifest build.json --fixture fixture.store + +A full-history checkout is required. New manifests must refer to a commit that +already contains schema-models.json. Historical fixture files are never rebuilt +from today's sources. """ import argparse import dataclasses +import hashlib +import json +import pathlib +import plistlib +import sqlite3 import os import re import subprocess @@ -107,10 +56,8 @@ "TokenLocalization", ] -# Every model registered by the versions that share the V1 graph, in the -# order `DashModelContainer` lists them, minus the two that have their own -# rows below (`PersistentAssetLock`, whose shape differs between V2 and V3, -# and `PersistentTrackedMasternode`, which V2 added). +# Accepted V1 model inventory, excluding the asset lock whose earlier shape +# has a separate unchanged row below. V1_GRAPH_MODELS = [ "PersistentIdentity", "PersistentDPNSName", @@ -160,11 +107,10 @@ class Freeze: FREEZES = [ - # The asset lock as V1 and V2 shipped it: the last commit before + # The accepted V1 asset lock: the last commit before # `recipientIsExternal` was added to the live model. Freeze("DashSchemaV1", "7127c38566", ("PersistentAssetLock",)), - # The rest of the graph, shared by V1, V2 and V3, at the last commit - # before V4 widened the wallet transaction models. + # The accepted V1 graph at the last commit before the sweep additions. Freeze( "DashSchemaV1", "5f58417079", @@ -172,10 +118,6 @@ class Freeze: TOKEN_TYPES_FILE, tuple(TOKEN_VALUE_TYPES), ), - # V2 adds the tracked-masternode registry. - Freeze("DashSchemaV2", "5f58417079", ("PersistentTrackedMasternode",)), - # V3 replaces the asset lock with the shape that has `recipientIsExternal`. - Freeze("DashSchemaV3", "5f58417079", ("PersistentAssetLock",)), ] HEADER = "import Foundation\nimport SwiftData\n\n" @@ -188,8 +130,8 @@ def git(root, *args): ) except subprocess.CalledProcessError as error: raise SystemExit( - f"git {' '.join(args)} failed (exit {error.returncode}); a commit named in " - "FREEZES may not be fetched locally" + f"git {' '.join(args)} failed (exit {error.returncode}); a baseline or " + "release source commit may not be fetched locally" ) @@ -340,7 +282,7 @@ def render_model(freeze, sha, source, model, sibling): ) -def render_all(root): +def render_baseline(root): """Every frozen file as {relative path: text}.""" # Names frozen under a schema, across all of its rows: any of them # mentioned inside an extension body must resolve to the nested copy. @@ -378,9 +320,200 @@ def emit(path, text): return files +REGISTRY_FILE = "packages/swift-sdk/schema-releases.json" +INVENTORY_FILE = "packages/swift-sdk/schema-models.json" +TEST_REGISTRY_FILE = "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift" +FIXTURE_DIR = "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/releases" + + +def read_registry(root): + with open(os.path.join(root, REGISTRY_FILE), encoding="utf-8") as source: + registry = json.load(source) + if registry.get("format_version") != 1 or not isinstance(registry.get("schemas"), dict): + raise SystemExit("unsupported schema release registry") + return registry + + +def validate_schema(schema): + if not isinstance(schema, dict) or set(schema) != { + "schema_version", "model_checksum", "entity_hashes", "indexes" + }: + raise SystemExit("invalid captured schema description") + version = schema["schema_version"] + if not isinstance(version, str) or not re.fullmatch(r"[1-9][0-9]*\.0\.0", version): + raise SystemExit("schema versions must be major.0.0") + if not isinstance(schema["model_checksum"], str) or not schema["model_checksum"]: + raise SystemExit("missing model checksum") + hashes = schema["entity_hashes"] + if not isinstance(hashes, dict) or not hashes or any( + not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name) + or not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]+", value) + for name, value in hashes.items() + ): + raise SystemExit("invalid entity hashes") + indexes = schema["indexes"] + if not isinstance(indexes, list) or any(not isinstance(item, str) for item in indexes): + raise SystemExit("invalid index description") + if indexes != sorted(set(indexes)): + raise SystemExit("indexes must be sorted and unique") + + +def read_inventory(root, commit): + inventory = json.loads(git(root, "show", f"{commit}:{INVENTORY_FILE}")) + if inventory.get("format_version") != 1: + raise SystemExit("unsupported historical schema model inventory") + models = inventory["models"] + groups = inventory["value_types"] + for name in list(models) + [name for group in groups for name in group["names"]]: + if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name): + raise SystemExit("invalid Swift name in model inventory") + for path in list(models.values()) + [group["path"] for group in groups]: + if not path.startswith("packages/swift-sdk/Sources/SwiftDashSDK/") or ".." in pathlib.PurePosixPath(path).parts: + raise SystemExit("model inventory path is outside the Swift SDK") + return inventory + + +def render_snapshot(root, version, entry): + validate_schema(entry["schema"]) + if entry["schema"]["schema_version"] != version: + raise SystemExit("registry key does not match captured schema version") + commit = entry["platform_sha"] + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise SystemExit("release source must be a full Git SHA") + namespace = "DashSchemaSnapshotV" + version.split(".")[0] + if entry["namespace"] != namespace: + raise SystemExit("unexpected snapshot namespace") + inventory = read_inventory(root, commit) + models = inventory["models"] + if set(models) != set(entry["schema"]["entity_hashes"]): + raise SystemExit("historical inventory differs from captured model membership") + names = set(models) | {name for group in inventory["value_types"] for name in group["names"]} + sibling = re.compile(r"(? Date: Sat, 19 Sep 2026 09:49:01 +0200 Subject: [PATCH 02/18] fix(swift-sdk): strengthen schema release validation --- .../Persistence/DashModelContainer.swift | 129 ++---------------- .../DashModelMigrationTests.swift | 43 ++++-- .../DashReleasedSchemaTests.swift | 6 + .../scripts/freeze_appstore_release.py | 3 +- .../swift-sdk/scripts/freeze_schema_models.py | 3 +- .../scripts/test_freeze_appstore_release.py | 17 ++- .../scripts/test_freeze_schema_models.py | 22 ++- 7 files changed, 86 insertions(+), 137 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 39a0447231d..b9a40e92ea1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -174,128 +174,15 @@ public enum DashMigrationPlan: SchemaMigrationPlan { } } -/// Version 1 of the Dash Platform schema -/// Includes `PersistentCoreAddress` to match the example app's former container schema. -/// The model is additive with optional relationships, so existing narrower stores can -/// use SwiftData's lightweight migration path. +/// The accepted historical baseline, pinned by the unchanged generated V1 +/// models and `Fixtures/SchemaStores/dash-v1.store`. Preserve those definitions +/// and fixture bytes when introducing later schema versions. /// -/// Note: this V1 identifier has accumulated several destructive -/// dev-only changes that cannot be expressed via the lightweight -/// migration path: -/// - `PersistentTransaction.txid` and the renamed -/// `PersistentTxo.outpoint` switched from `String` to raw `Data` -/// (unique-attribute retype). -/// - The `PersistentUtxo` model was renamed to `PersistentTxo`, -/// gained `walletId` + `spendingTransaction`, and the schema -/// topology shifted: `PersistentTransaction` lost both -/// `walletId` and `account` and now hangs on transactions purely -/// through the `outputs` / `inputs` TXO relationships. -/// - `PersistentAccount.outputs` (the cascade-owned -/// `[PersistentTxo]` collection paired with -/// `PersistentTxo.account`) was removed. Per-account TXOs are -/// now derived through `coreAddresses.flatMap(\.txos)` — -/// `PersistentTxo.account` survives as a one-way fallback -/// pointer with no inverse. Removing the inverse changes the -/// relationship topology for the underlying SQLite store, so -/// existing dev stores can't be opened with the new schema. -/// - `PersistentAccount.wallet` was tightened from -/// `PersistentWallet?` to non-optional `PersistentWallet`. Every -/// account currently belongs to a wallet; the type system now -/// reflects that invariant. Switching the optionality of a -/// relationship column rewrites the SQLite schema, so existing -/// dev stores can't be reused. -/// - `PersistentWallet.isWatchOnly` and -/// `PersistentAccount.isWatchOnly` were removed. The runtime -/// watch-only state lives on the native `Wallet` / -/// `ManagedAccount` (FFI-backed); persisting it on the SwiftData -/// side was redundant and the persister never wrote it. -/// - `PersistentDPNSName` was added (cascade-owned by -/// `PersistentIdentity` via the new `dpnsNames` relationship) -/// so DPNS labels are persisted instead of recomputed on every -/// `IdentityDetailView` open. Existing dev stores predate the -/// row collection and rebuild on next sync; the changeset's -/// append-only merge policy populates the new rows from the -/// persister callback. -/// - `PersistentDashpayProfile` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayProfile` optional -/// relationship). Mirrors `IdentityEntry::dashpay_profile` from -/// the FFI so DashPay profile fields (display name, public -/// message, avatar URL / hash / fingerprint, bio) are persisted -/// across launches instead of being refetched. Existing dev -/// stores predate the row and rebuild on next profile sync; the -/// persister upserts in place via -/// `PlatformWalletPersistenceHandler.upsertDashpayProfile`. -/// - `PersistentDashpayContactRequest` was added (cascade-owned by -/// `PersistentIdentity` via the new `contactRequests` collection). -/// Mirrors `ContactChangeSet::sent_requests` / -/// `incoming_requests` / `established` projected through the new -/// `on_persist_contacts_fn` FFI callback, with one row per -/// `(network, owner, contact, isOutgoing)` quad. Existing dev -/// stores predate the row collection and rebuild on next -/// DashPay contact sync. -/// - `PersistentDashpayContactRequest` gained the additive -/// `paymentChannelBroken` column (defaulted `false`) so the G1c -/// broken-channel flag projected by the persister survives -/// restarts. Additive-with-default ⇒ lightweight migration. -/// - `PersistentDashpayPayment` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayPayments` -/// collection). Mirrors the per-identity `dashpay_payments` map -/// read through `managed_identity_get_dashpay_payments`; rows are -/// refreshed by `PlatformWalletManager.refreshDashPayPayments` -/// (the persister doesn't project payment history). Additive -/// model + additive relationship ⇒ lightweight migration. -/// - `PersistentDashpayIgnoredSender` was added (cascade-owned by -/// `PersistentIdentity` via the new `dashpayIgnoredSenders` -/// collection). Persists per-sender ignores (local-only mute, = -/// block, reversible) the persister projects in the `ignored` -/// changeset array so the Rust `ignored_senders` set can be restored -/// at load — without it an ignored sender resurfaces on relaunch. -/// Keyed per-sender (no `accountReference`), so an ignored sender's -/// rotated requests are suppressed too. Additive model + additive -/// relationship ⇒ lightweight migration. (Replaces the earlier -/// per-`(sender, accountReference)` `PersistentDashpayRejectedRequest` -/// — the model decision collapsed reject into ignore.) -/// - `PersistentDashpayContactProfile` was added (cascade-owned by -/// `PersistentIdentity` via the new `contactProfiles` collection). -/// Mirrors one entry of the per-identity `contact_profiles` map -/// (cached contacts' public profiles, keyed by the contact's -/// identity id) projected by the persister as -/// `IdentityEntryFFI.contact_profiles` rows, and read back at load to -/// rebuild the Rust cache so contacts don't refetch on every -/// relaunch. Distinct from `PersistentDashpayProfile` (the owner's -/// own profile). Additive model + additive relationship ⇒ -/// lightweight migration. -/// - `PersistentAccount` gained `#Unique<…>([\.wallet, \.accountType, -/// \.accountIndex, \.userIdentityId, \.friendIdentityId])` plus -/// `@Attribute(.unique)` on `accountExtendedPubKeyBytes`. The -/// xpub field also flipped from `Data` to `Data?` so multiple -/// unhydrated rows (xpub not yet known) don't collide on the -/// UNIQUE constraint — SQL allows multiple `NULL`s. Together -/// these enforce "one row per account identity, one xpub per -/// account" at the database layer; pre-refactor the persister's -/// `applyAccountChangeset` was string-keyed on the legacy -/// `Debug`-formatted `account_type_name` and could grow -/// duplicate rows for the same logical account. -/// - `PersistentTokenBalance.balance` remains the original `Int64` SwiftData -/// property and SQLite column. Protocol `u64` values use its raw bits via a -/// computed accessor, so full-domain support does not alter this V1 schema. -/// - `PersistentDPNSName` gained the DPNS username-marketplace -/// columns `documentIdBase58`, `priceCredits`, `saleStatusRaw`, -/// `counterpartyIdBase58`, the three optional document timestamps, -/// and `marketplaceUpdatedAt`, written by -/// the new `on_persist_dpns_name_states_fn` persister callback -/// (`DpnsNameStateFFI`). All optional or defaulted, and the -/// `(networkRaw, normalizedParentDomainName, normalizedLabel)` -/// uniqueness is unchanged ⇒ lightweight migration. Existing rows -/// migrate with a nil `documentIdBase58`, which is the documented -/// "no marketplace state tracked" signal — the next marketplace -/// sync pass fills them in. -/// Each of those is a destructive change to a unique-attribute -/// column or to relationship topology, so any pre-existing dev -/// store will fail to open and get rebuilt from scratch on next -/// sync. Bumping the version isn't useful without a real -/// `MigrationStage` (and there's nothing worth preserving in dev -/// databases at this point), so we let the container recreate. +/// Migration tests establish compatibility from this accepted baseline into +/// the current live schema. They do not reconstruct or verify the database +/// written by the original App Store binary. Other historical development +/// layouts are unsupported: opening an unrecognized store throws an error; +/// the container does not silently erase or recreate it. public enum DashSchemaV1: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index f29e9251acd..443f92343ed 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -33,7 +33,7 @@ final class DashModelMigrationTests: XCTestCase { hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), ] - private static let shippedVersions = ["1.0.0"] + private static let acceptedBaselineVersions = [Schema.Version(1, 0, 0)] private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) @@ -169,8 +169,8 @@ final class DashModelMigrationTests: XCTestCase { /// Captured App Store versions are covered by DashReleasedSchemaTests. func testFrozenVersionsBuiltAfterTheLiveSchemaHashLikeTheStoresTheyShipped() throws { XCTAssertEqual( - Self.fixtures.map { Self.describe($0.version.versionIdentifier) }, - Self.shippedVersions, + Self.fixtures.map { $0.version.versionIdentifier }, + Self.acceptedBaselineVersions, "the accepted baseline must retain its existing fixture") for fixture in Self.fixtures { @@ -202,13 +202,31 @@ final class DashModelMigrationTests: XCTestCase { "\(version.major).\(version.minor).\(version.patch)" } - func testAcceptedBaselineRemainsInTheMigrationPlan() { + func testMigrationPlanContainsBaselinePublishedAndLiveVersionsInOrder() { + let publishedVersions = DashReleasedSchemaRegistry.fixtures.map { + $0.version.versionIdentifier + } + let expected = Set( + Self.acceptedBaselineVersions + publishedVersions + [DashModelContainer.schema.version] + ).sorted() XCTAssertEqual( - DashMigrationPlan.schemas.prefix(Self.shippedVersions.count).map { - Self.describe($0.versionIdentifier) - }, Self.shippedVersions) - let versions = DashMigrationPlan.schemas.map { Self.describe($0.versionIdentifier) } - XCTAssertEqual(Set(versions).count, versions.count) + DashMigrationPlan.schemas.map { $0.versionIdentifier }, expected, + "The plan must retain the accepted baseline and every published version, followed by the live version") + } + + func testMigrationStagesConnectAdjacentRegisteredSchemas() { + let schemas = DashMigrationPlan.schemas + let stages = DashMigrationPlan.stages + XCTAssertEqual(stages.count, schemas.count - 1) + for (stage, adjacent) in zip(stages, zip(schemas, schemas.dropFirst())) { + switch stage { + case .lightweight(let from, let to), .custom(let from, let to, _, _): + XCTAssertEqual(ObjectIdentifier(from), ObjectIdentifier(adjacent.0)) + XCTAssertEqual(ObjectIdentifier(to), ObjectIdentifier(adjacent.1)) + @unknown default: + XCTFail("Unsupported migration stage") + } + } } /// The schema the app opens stores with must be the last version of @@ -223,6 +241,13 @@ final class DashModelMigrationTests: XCTestCase { Self.describe(DashModelContainer.schema.version), Self.describe(last.versionIdentifier), "DashModelContainer.schema must be built from the migration plan's last version") + let registeredModels = last.models.map { ObjectIdentifier($0) } + let liveModels = DashModelContainer.modelTypes.map { ObjectIdentifier($0) } + XCTAssertEqual(Set(registeredModels).count, registeredModels.count) + XCTAssertEqual(registeredModels.count, liveModels.count) + XCTAssertEqual( + Set(registeredModels), Set(liveModels), + "The last version must register the live model types used by SDK callers") } /// The SQLite indexes of a store, one line per index: table, name and diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index 17eca733ac3..35dfdbc0097 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -26,6 +26,9 @@ final class DashReleasedSchemaTests: XCTestCase { @MainActor func testPublishedSnapshotsAndRuntimeVersionsMatchCapturedStores() throws { + try XCTSkipIf( + DashReleasedSchemaRegistry.fixtures.isEmpty, + "No App Store schema snapshots have been registered yet") for fixture in DashReleasedSchemaRegistry.fixtures { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -52,6 +55,9 @@ final class DashReleasedSchemaTests: XCTestCase { @MainActor func testPublishedStoresMigrateAndRemainWritableThroughLiveTypes() throws { + try XCTSkipIf( + DashReleasedSchemaRegistry.fixtures.isEmpty, + "No App Store schema snapshots have been registered yet") for fixture in DashReleasedSchemaRegistry.fixtures { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index b156807a90c..eea57a25e96 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -10,6 +10,7 @@ import argparse import base64 +import contextlib import hashlib import json import os @@ -289,7 +290,7 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): fixture_file = Path(temporary) / "fixture.store" manifest_file.write_text(json.dumps(manifest)) fixture_file.write_bytes(fixture) - with sqlite3.connect(f"file:{fixture_file}?immutable=1", uri=True) as database: + with contextlib.closing(sqlite3.connect(f"file:{fixture_file}?immutable=1", uri=True)) as database: if database.execute("PRAGMA quick_check").fetchone() != ("ok",): raise ReleaseError("The release fixture is corrupt or needs a WAL file") run(clone, sys.executable, GENERATOR, "--release-manifest", str(manifest_file), "--fixture", str(fixture_file)) diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index dc2325b9263..29ba23f9d8e 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -29,6 +29,7 @@ """ import argparse +import contextlib import dataclasses import hashlib import json @@ -447,7 +448,7 @@ def validate_fixture_description(path, schema): """Check captured metadata without opening or migrating the store in SwiftData.""" uri = pathlib.Path(path).resolve().as_uri() + "?mode=ro&immutable=1" try: - with sqlite3.connect(uri, uri=True) as database: + with contextlib.closing(sqlite3.connect(uri, uri=True)) as database: row = database.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone() metadata = plistlib.loads(row[0]) if metadata.get("NSStoreModelVersionIdentifiers") != [schema["schema_version"]]: diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index 65cf61399c3..d3cadf8c6bb 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -40,7 +40,7 @@ def setUp(self): git(self.data, "config", "commit.gpgsign", "false") git(self.data, "remote", "add", "origin", f"https://github.com/{worker.IOS_REPO}.git") self.store = self.root / "synthetic.store" - with sqlite3.connect(self.store) as database: + with contextlib.closing(sqlite3.connect(self.store)) as database, database: database.execute("CREATE TABLE synthetic(value TEXT)") database.execute("INSERT INTO synthetic VALUES ('test')") self.fixture = self.store.read_bytes() @@ -247,6 +247,21 @@ def test_dry_run_does_not_publish_or_modify_checkout(self): self.api.request.assert_not_called() self.assertEqual(git(self.platform, "status", "--porcelain"), "") + def test_fixture_check_closes_connection_on_success_or_failure(self): + for corrupt in (False, True): + database = sqlite3.connect(self.store) + checked_database = mock.Mock(wraps=database) + if corrupt: + checked_database.execute.return_value.fetchone.return_value = ("corrupt",) + with mock.patch.object(worker.sqlite3, "connect", return_value=checked_database): + if corrupt: + with self.assertRaisesRegex(worker.ReleaseError, "corrupt or needs a WAL"): + self.prepare(dry_run=True) + else: + self.prepare(dry_run=True) + with self.assertRaises(sqlite3.ProgrammingError): + database.execute("SELECT 1") + def test_push_then_retry_reuses_draft_pr_and_commit(self): self.prepare() self.assertTrue(self.api.request.call_args.args[2]["draft"]) diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 277ceedad7c..9649dd87b4a 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -13,6 +13,7 @@ """ import os +import contextlib import hashlib import json import plistlib @@ -103,7 +104,7 @@ def setUp(self): self.schema = { "schema_version": "2.0.0", "model_checksum": "checksum", "entity_hashes": {"PersistentThing": "abcd"}, "indexes": []} - with sqlite3.connect(self.fixture) as database: + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: database.execute("CREATE TABLE Z_METADATA (Z_PLIST BLOB)") metadata = {"NSStoreModelVersionIdentifiers": ["2.0.0"], "NSStoreModelVersionChecksumKey": "checksum", "NSStoreModelVersionHashes": {"PersistentThing": bytes.fromhex("abcd")}} database.execute("INSERT INTO Z_METADATA VALUES (?)", (plistlib.dumps(metadata, fmt=plistlib.FMT_BINARY),)) @@ -138,9 +139,22 @@ def test_should_copy_exact_captured_commit_and_preserve_release_metadata(self): self.assertIn("enum DashSchemaSnapshotV2", rendered[f"{gen.OUT_DIR}/DashSchemaSnapshotV2+Schema.swift"]) self.assertTrue(all("a" * 40 + ":" in args[-1] for args in self.git_calls)) + def test_should_close_fixture_connection_after_success_or_validation_failure(self): + for version in ("2.0.0", "3.0.0"): + database = sqlite3.connect(self.fixture) + schema = dict(self.schema, schema_version=version) + with mock.patch.object(gen.sqlite3, "connect", return_value=database): + if version == "2.0.0": + gen.validate_fixture_description(self.fixture, schema) + else: + with self.assertRaisesRegex(SystemExit, "schema version does not match"): + gen.validate_fixture_description(self.fixture, schema) + with self.assertRaises(sqlite3.ProgrammingError): + database.execute("SELECT 1") + def test_should_reuse_same_shape_even_when_a_later_build_has_different_bytes_and_sha(self): first = gen.add_release(self.root, self.manifest, self.fixture) - with sqlite3.connect(self.fixture) as database: + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: database.execute("CREATE TABLE unimportant (value INTEGER)") digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() next_manifest = {**self.manifest, "platform_sha": "b" * 40, @@ -153,7 +167,7 @@ def test_should_reject_changed_shape_under_published_version_including_indexes(s for field in ["model_checksum", "indexes"]: self.fixture.write_bytes(original) schema = dict(self.schema) - with sqlite3.connect(self.fixture) as database: + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: if field == "model_checksum": metadata = plistlib.loads(database.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]) metadata["NSStoreModelVersionChecksumKey"] = "changed" @@ -170,7 +184,7 @@ def test_should_reject_changed_shape_under_published_version_including_indexes(s def test_should_reject_duplicate_checksum_under_another_version(self): gen.add_release(self.root, self.manifest, self.fixture) - with sqlite3.connect(self.fixture) as database: + with contextlib.closing(sqlite3.connect(self.fixture)) as database, database: metadata = plistlib.loads(database.execute("SELECT Z_PLIST FROM Z_METADATA").fetchone()[0]) metadata["NSStoreModelVersionIdentifiers"] = ["3.0.0"] database.execute("UPDATE Z_METADATA SET Z_PLIST = ?", (plistlib.dumps(metadata),)) From 10e1062854651d46eb882cc9c31511f0a331d050 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sat, 19 Sep 2026 18:27:03 +0200 Subject: [PATCH 03/18] fix(swift-sdk): isolate release generation and retain source history --- .editorconfig | 3 + .../workflows/swift-sdk-freeze-release.yml | 3 +- packages/swift-sdk/SCHEMA_RELEASES.md | 28 +++- .../DashModelMigrationTests.swift | 2 +- .../scripts/freeze_appstore_release.py | 96 +++++++++++-- .../swift-sdk/scripts/freeze_schema_models.py | 15 ++- .../scripts/test_freeze_appstore_release.py | 127 +++++++++++++++++- 7 files changed, 251 insertions(+), 23 deletions(-) diff --git a/.editorconfig b/.editorconfig index 4238a16b9d5..dff33a306c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,8 @@ end_of_line = lf [*.rs] indent_size = 4 +[packages/swift-sdk/scripts/*.py] +indent_size = 4 + [*.{md,markdown}] trim_trailing_whitespace = false diff --git a/.github/workflows/swift-sdk-freeze-release.yml b/.github/workflows/swift-sdk-freeze-release.yml index 36a92b395d0..00dec5251bb 100644 --- a/.github/workflows/swift-sdk-freeze-release.yml +++ b/.github/workflows/swift-sdk-freeze-release.yml @@ -35,6 +35,7 @@ jobs: with: ref: v4.2-dev path: platform + # Include immutable swift-schema-source/* tags and their history. fetch-depth: 0 persist-credentials: false - name: Checkout publication records from the fixed iOS repository @@ -58,4 +59,4 @@ jobs: set -euo pipefail args=(--release-id "$RELEASE_ID" --data-commit "$DATA_COMMIT" --data-repo ../release-data) if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi - python3 packages/swift-sdk/scripts/freeze_appstore_release.py "${args[@]}" + python3 -I packages/swift-sdk/scripts/freeze_appstore_release.py "${args[@]}" diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 9a530745bb5..f790681f17b 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -17,11 +17,28 @@ commit; the latest development commit is never substituted. Versions marked `REPLACED_WITH_NEW_VERSION` also count as published history: a release superseded between the twice-daily checks still requires its snapshot. +Before storing candidate evidence or uploading to TestFlight, iOS retains the +Platform commit under the lightweight tag `swift-schema-source/`. +This tag is source retention, not a schema freeze or a product release: it lets +the later freeze read that exact commit even if its development branch has been +force-updated during Apple review. The worker also verifies/creates these tags +for every registered source before publishing a snapshot PR. Existing tags must +point directly to their named commit; mismatches stop processing and are never +force-updated. Full-history Actions checkouts fetch these tags. A manual shallow +checkout must fetch the retained source tags before regenerating snapshots. + The monitor dispatches **Freeze SwiftData App Store release** with `release_id` and `data_commit`. The worker verifies that this commit belongs to the metadata branch, verifies the proof, build identity and artifact digests, and generates the snapshot from the manifest's Platform commit. It uses a temporary clone; the source checkout is unchanged. +The generator executable is copied from the reviewed `v4.2-dev` base into a +separate temporary directory before the draft is checked out. It runs with an +explicit target repository, isolated Python imports and a credential-free +environment. Draft files are input/output data, including any edits to the +generator itself; they are not executed by this worker. Only authenticated Git +fetch/push processes receive the PAT, with ambient Git configuration and hooks +disabled. Human changes on the draft remain available for review. The worker opens a draft PR on `codex/freeze-swift-schema-v`, targeting `v4.2-dev`. The PR contains the generated snapshot, synthetic fixture @@ -49,7 +66,7 @@ all required releases, not merely a commit from before the snapshot merge. 4. After publication, use the iOS manual monitor for the normal operator flow. For a worker retry, select the recorded Apple version ID and a full commit on `schema-release-data`. Select `dry_run` to validate and generate the patch - without committing, pushing or creating a PR. Dry runs still require read + without committing, creating source tags, pushing or creating a PR. Dry runs still require read credentials and query GitHub. Do not use the Platform workflow to bypass App Store publication: it requires @@ -57,6 +74,9 @@ a published-state proof written by the trusted iOS monitor. Protect the data branch against deletion/force-push and restrict write access to release operators and automation. On PAT expiry, replace the repository secret in both repos and retry; no schema should be regenerated manually just to recover authentication. +Protect `swift-schema-source/*` tags against update/deletion while allowing the +release automation to create new ones. These tags do not match this repository's +branch-only push workflows and do not trigger product release publishing. ## Developing the next schema @@ -89,6 +109,12 @@ and never contain user wallet material. - A missing build record, changed digest, conflicting schema number or rewritten release association stops processing. Restore the correct original record through the release-data recovery process; never guess a source SHA. +- If a retained source tag is missing, rerun the worker while the exact original + commit remains available. It verifies all registry sources and recreates only + missing references. If the commit has already been garbage-collected, recover + that exact object from an original checkout/backup first; a replacement commit + with similar source does not satisfy its identity. A conflicting tag requires + investigation instead of an automatic overwrite. - An unsupported development database may require an explicit app-data reset by its owner. Export/recover any needed development wallet first. The app does not silently erase an unrecognized database to make migrations succeed. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 443f92343ed..7cad0111eff 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -33,7 +33,7 @@ final class DashModelMigrationTests: XCTestCase { hasTrackedMasternode: false, assetLockRecipientIsExternal: nil), ] - private static let acceptedBaselineVersions = [Schema.Version(1, 0, 0)] + private static var acceptedBaselineVersions: [Schema.Version] { [Schema.Version(1, 0, 0)] } private static let fixtureWalletId = Data(repeating: 0x31, count: 32) private static let fixtureSpendTxid = Data(repeating: 0x32, count: 32) diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index eea57a25e96..875f6df9d2d 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -38,14 +38,26 @@ DIGEST = re.compile(r"[0-9a-f]{64}") COMPONENT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,254}") VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +SOURCE_TAG_PREFIX = "refs/tags/swift-schema-source/" class ReleaseError(RuntimeError): pass +def process_environment(): + """Branch files are data, never a source of credentials or Git/Python setup.""" + return { + "PATH": os.defpath, "HOME": os.devnull, "LC_ALL": "C", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_TERMINAL_PROMPT": "0", "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.hooksPath", "GIT_CONFIG_VALUE_0": os.devnull, + } + + def run(directory, *args, env=None): - result = subprocess.run(args, cwd=directory, env=env, capture_output=True) + result = subprocess.run(args, cwd=directory, + env=process_environment() if env is None else env, capture_output=True) if result.returncode: # Never echo commands or credentials from subprocess diagnostics. raise ReleaseError(f"{args[0]} {args[1] if len(args) > 1 else ''} failed: " @@ -63,9 +75,10 @@ def require_string(value, pattern, label): return value -def read_blob(directory, commit, path): +def read_blob(directory, commit, path, *, allow_executable=False): entry = git(directory, "ls-tree", commit, "--", path) - if not entry.startswith("100644 blob "): + modes = ("100644 blob ", "100755 blob ") if allow_executable else ("100644 blob ",) + if not entry.startswith(modes): raise ReleaseError(f"Missing regular data file: {path}") return run(directory, "git", "show", f"{commit}:{path}") @@ -217,14 +230,55 @@ def pull_requests(self, branch): def git_environment(token): - env = os.environ.copy() + env = process_environment() # This is process-local and never written to git config or command output. credential = base64.b64encode(f"x-access-token:{token}".encode()).decode() - env.update(GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="http.https://github.com/.extraheader", - GIT_CONFIG_VALUE_0=f"AUTHORIZATION: basic {credential}", GIT_TERMINAL_PROMPT="0") + env.update(GIT_CONFIG_COUNT="2", GIT_CONFIG_KEY_1="http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_1=f"AUTHORIZATION: basic {credential}") return env +def source_commits(registry): + return { + require_string(entry.get("platform_sha"), SHA, "registered Platform commit") + for group in ("schemas", "releases") for entry in registry.get(group, {}).values() + } + + +def fetch_sources(clone, commits, env): + """Fetch all recorded sources, including objects outside development history.""" + git(clone, "fetch", "origin", f"{SOURCE_TAG_PREFIX}*:{SOURCE_TAG_PREFIX}*", env=env) + for commit in sorted(commits): + require_string(commit, SHA, "source commit") + git(clone, "fetch", "origin", commit, env=env) + if git(clone, "cat-file", "-t", commit) != "commit": + raise ReleaseError("A registered source SHA does not identify a commit") + + +def retain_source(clone, commit, env): + """Publish a lightweight immutable source tag, reconciling concurrent retries.""" + require_string(commit, SHA, "source commit") + ref = SOURCE_TAG_PREFIX + commit + + def existing(): + row = git(clone, "ls-remote", "--refs", "origin", ref, env=env) + if not row: + return False + if row != f"{commit}\t{ref}": + raise ReleaseError(f"Source retention tag points to a different object: {ref}") + return True + + if existing(): + return + try: + git(clone, "push", "origin", f"{commit}:{ref}", env=env) + except ReleaseError: + # A concurrent upload may have created the exact same immutable tag, + # or the server accepted the push before the connection was lost. + if not existing(): + raise + + def pr_body(proof, manifest, data_commit): schema = manifest["schema"]["schema_version"] return f"""## Issue being fixed or feature implemented @@ -263,10 +317,26 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): git(clone, "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com") git(clone, "config", "commit.gpgsign", "false") git(clone, "fetch", "origin", f"{BASE_BRANCH}:refs/remotes/origin/{BASE_BRANCH}", env=env) + # Read executable code only from the reviewed base. A contributor may + # edit code on the draft branch; it must never become this worker's + # generator, even when running a dry-run with no remote writes. + trusted_generator = Path(temporary) / "trusted-freeze-schema-models.py" + trusted_generator.write_bytes(read_blob(clone, f"origin/{BASE_BRANCH}", GENERATOR, + allow_executable=True)) + + def generate(*args): + run(temporary, sys.executable, "-I", str(trusted_generator), "--repo", str(clone), *args) + git(clone, "checkout", "--detach", f"origin/{BASE_BRANCH}") merged_registry = json_object((clone / REGISTRY).read_bytes(), "merged snapshot registry") if release_id in merged_registry.get("releases", {}): associate_release(merged_registry, release_id, release_entry(proof, manifest, data_commit)) + commits = source_commits(merged_registry) | {manifest["platform_sha"]} + fetch_sources(clone, commits, env) + generate("--check") + if not dry_run: + for commit in sorted(commits): + retain_source(clone, commit, env) print("This release is already present in the merged registry.") return remote_branch = git(clone, "ls-remote", "--heads", "origin", branch, env=env) @@ -278,11 +348,9 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): git(clone, "merge", "--no-commit", "--no-ff", f"origin/{BASE_BRANCH}") else: git(clone, "checkout", "-b", branch, f"origin/{BASE_BRANCH}") - # A released SHA can be absent from current branch history after a - # force-updated development branch. Fetch exactly the proven SHA. - git(clone, "fetch", "origin", manifest["platform_sha"], env=env) - run(clone, sys.executable, GENERATOR, "--check") before_registry = json_object((clone / REGISTRY).read_bytes(), "snapshot registry") + fetch_sources(clone, source_commits(before_registry) | {manifest["platform_sha"]}, env) + generate("--check") immutable_files = {path: (clone / path).read_bytes() for path in git(clone, "ls-files").splitlines() if permitted_change(path) and path not in (REGISTRY, GENERATED_TEST)} @@ -293,7 +361,7 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): with contextlib.closing(sqlite3.connect(f"file:{fixture_file}?immutable=1", uri=True)) as database: if database.execute("PRAGMA quick_check").fetchone() != ("ok",): raise ReleaseError("The release fixture is corrupt or needs a WAL file") - run(clone, sys.executable, GENERATOR, "--release-manifest", str(manifest_file), "--fixture", str(fixture_file)) + generate("--release-manifest", str(manifest_file), "--fixture", str(fixture_file)) if any(not (clone / path).is_file() or (clone / path).read_bytes() != content for path, content in immutable_files.items()): raise ReleaseError("Attempted to replace an existing snapshot or fixture") @@ -305,7 +373,7 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): raise ReleaseError(f"Attempted to rewrite existing {key} entry: {identifier}") associate_release(registry, release_id, release_entry(proof, manifest, data_commit)) (clone / REGISTRY).write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n") - run(clone, sys.executable, GENERATOR, "--check") + generate("--check") # Inspect only unstaged changes: a non-conflicting base merge may have # staged unrelated source updates, which are preserved in the merge. changed = set(git(clone, "diff", "--name-only").splitlines()) @@ -316,6 +384,10 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): "files": sorted(changed), "dry_run": dry_run}, indent=2)) if dry_run: return + # The source objects must remain reachable in a fresh checkout before + # the PR references them. Neither tags nor source history are rewritten. + for commit in sorted(source_commits(registry) | {manifest["platform_sha"]}): + retain_source(clone, commit, env) if changed: git(clone, "add", "--", *sorted(changed)) staged = git(clone, "diff", "--cached", "--name-only") diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index 29ba23f9d8e..2685f4a2ff9 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -23,9 +23,11 @@ python3 packages/swift-sdk/scripts/freeze_schema_models.py \ --release-manifest build.json --fixture fixture.store -A full-history checkout is required. New manifests must refer to a commit that -already contains schema-models.json. Historical fixture files are never rebuilt -from today's sources. +A full-history checkout including swift-schema-source/* tags is required. New +manifests must refer to a commit that already contains schema-models.json. +Historical fixture files are never rebuilt from today's sources. --repo selects +the data repository explicitly when a trusted copy of this generator runs from +outside the checkout. """ import argparse @@ -136,8 +138,8 @@ def git(root, *args): ) -def repo_root(): - return git(os.getcwd(), "rev-parse", "--show-toplevel").strip() +def repo_root(directory=None): + return git(directory or os.getcwd(), "rev-parse", "--show-toplevel").strip() def strip_comments(lines): @@ -539,6 +541,7 @@ def check_problems(root, files): def main(): parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--repo", help="repository containing the schema data; defaults to the current directory") parser.add_argument( "--check", action="store_true", @@ -549,7 +552,7 @@ def main(): args = parser.parse_args() if bool(args.release_manifest) != bool(args.fixture) or (args.check and args.release_manifest): parser.error("--release-manifest and --fixture are required together and cannot use --check") - root = repo_root() + root = repo_root(args.repo) if args.release_manifest: with open(args.release_manifest, encoding="utf-8") as source: add_release(root, json.load(source), args.fixture) diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index d3cadf8c6bb..7d2ebba8c5b 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -4,6 +4,7 @@ import hashlib import io import json +import os from pathlib import Path import sqlite3 import subprocess @@ -206,8 +207,17 @@ def setUp(self): write_json(self.platform / worker.REGISTRY, {"format_version": 1, "schemas": {}, "releases": {}}) generator = self.platform / worker.GENERATOR generator.parent.mkdir(parents=True) - generator.write_text('''import json, pathlib, sys + generator.write_text('''import json, os, pathlib, subprocess, sys +assert sys.flags.isolated +assert "SCHEMA_RELEASE_TOKEN" not in os.environ +assert "GITHUB_TOKEN" not in os.environ +assert "GIT_CONFIG_VALUE_1" not in os.environ +os.chdir(sys.argv[sys.argv.index("--repo") + 1]) if "--check" in sys.argv: + registry = json.loads(pathlib.Path("packages/swift-sdk/schema-releases.json").read_text()) + for group in ("schemas", "releases"): + for entry in registry[group].values(): + subprocess.run(["git", "cat-file", "-e", entry["platform_sha"] + "^{commit}"], check=True) sys.exit(0) m = json.loads(pathlib.Path(sys.argv[sys.argv.index("--release-manifest") + 1]).read_text()) p = pathlib.Path("packages/swift-sdk/schema-releases.json") @@ -218,6 +228,7 @@ def setUp(self): r["schemas"].setdefault(v, {"schema": m["schema"], "platform_sha": m["platform_sha"]}) p.write_text(json.dumps(r)) ''') + generator.chmod(0o755) git(self.platform, "add", ".") git(self.platform, "commit", "-m", "base") self.manifest["platform_sha"] = git(self.platform, "rev-parse", "HEAD") @@ -252,7 +263,9 @@ def test_fixture_check_closes_connection_on_success_or_failure(self): database = sqlite3.connect(self.store) checked_database = mock.Mock(wraps=database) if corrupt: - checked_database.execute.return_value.fetchone.return_value = ("corrupt",) + cursor = mock.Mock() + cursor.fetchone.return_value = ("corrupt",) + checked_database.execute.return_value = cursor with mock.patch.object(worker.sqlite3, "connect", return_value=checked_database): if corrupt: with self.assertRaisesRegex(worker.ReleaseError, "corrupt or needs a WAL"): @@ -295,6 +308,116 @@ def test_human_edits_on_existing_branch_are_preserved(self): self.prepare() self.assertEqual(git(self.remote, "show", f"{branch}:human-review.txt"), "Keep this review change") + def test_draft_generator_and_imports_never_run_with_ambient_credentials(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.platform, "fetch", str(self.remote), branch) + git(self.platform, "checkout", "-b", "untrusted-draft", "FETCH_HEAD") + marker = self.root / "untrusted-code-executed" + attack = f"import pathlib\npathlib.Path({str(marker)!r}).write_text('executed')\nraise RuntimeError('draft code ran')\n" + (self.platform / worker.GENERATOR).write_text(attack) + (self.platform / "json.py").write_text(attack) + (self.platform / "sitecustomize.py").write_text(attack) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", "unreviewed executable changes") + git(self.platform, "push", str(self.remote), f"HEAD:refs/heads/{branch}") + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + with mock.patch.dict(os.environ, {"SCHEMA_RELEASE_TOKEN": "synthetic-secret", + "GITHUB_TOKEN": "synthetic-other-secret", + "PYTHONPATH": str(self.platform)}): + self.prepare(dry_run=True) + self.prepare() + self.assertFalse(marker.exists()) + self.assertEqual(git(self.remote, "show", f"{branch}:{worker.GENERATOR}"), attack.strip()) + + def test_ambient_git_hooks_never_receive_worker_credentials(self): + hooks = self.root / "hooks" + hooks.mkdir() + marker = self.root / "hook-ran" + for name in ("post-checkout", "pre-commit", "pre-push"): + hook = hooks / name + hook.write_text(f"#!/bin/sh\ntouch '{marker}'\nexit 1\n") + hook.chmod(0o755) + config = self.root / "global.gitconfig" + config.write_text(f"[core]\n hooksPath = {hooks}\n") + git(self.root, "config", "--file", str(config), "filter.capture.smudge", f"touch '{marker}'; cat") + git(self.root, "config", "--file", str(config), "filter.capture.clean", f"touch '{marker}'; cat") + (self.platform / ".gitattributes").write_text("* filter=capture\n") + git(self.platform, "add", ".gitattributes") + git(self.platform, "commit", "-m", "draft-controlled filter selection") + git(self.platform, "push", str(self.remote), worker.BASE_BRANCH) + with mock.patch.dict(os.environ, {"GIT_CONFIG_GLOBAL": str(config), + "SCHEMA_RELEASE_TOKEN": "synthetic-secret"}): + self.prepare() + self.assertFalse(marker.exists()) + + def unreachable_source(self, name): + """Publish a source commit, then remove its only advertised branch.""" + git(self.platform, "checkout", "-b", name) + (self.platform / f"{name}.txt").write_text(name) + git(self.platform, "add", ".") + git(self.platform, "commit", "-m", name) + commit = git(self.platform, "rev-parse", "HEAD") + git(self.platform, "push", str(self.remote), f"HEAD:refs/heads/{name}") + git(self.platform, "checkout", worker.BASE_BRANCH) + git(self.platform, "branch", "-D", name) + git(self.remote, "update-ref", "-d", f"refs/heads/{name}") + return commit + + def test_source_tag_retains_rewritten_history_for_fresh_clones_and_later_freezes(self): + source = self.unreachable_source("released-before-force-push") + self.manifest["platform_sha"] = source + self.commit = self.save() + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + source), source) + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", git(self.remote, "rev-parse", branch)) + git(self.remote, "reflog", "expire", "--expire=now", "--all") + git(self.remote, "gc", "--prune=now") + fresh = self.root / "fresh" + git(self.root, "clone", "--no-local", str(self.remote), str(fresh)) + self.assertEqual(git(fresh, "cat-file", "-t", source), "commit") + git(fresh, "config", "user.name", "Test") + git(fresh, "config", "user.email", "test@example.invalid") + git(fresh, "config", "commit.gpgsign", "false") + self.platform = fresh + second_source = self.unreachable_source("second-released-before-force-push") + self.manifest.update(platform_sha=second_source, app_version="3.0", build_number="31") + self.manifest["schema"].update(schema_version="3.0.0", model_checksum="new-checksum") + self.proof.update(release_id="release-31", app_version="3.0", build_number="31", + build_id="build-31", manifest_path="builds/org.dash.wallet/3.0/31/manifest.json") + self.commit = self.save() + without_tags = self.root / "checkout-without-source-tags" + git(self.root, "clone", "--no-local", "--no-tags", "--single-branch", "--branch", + worker.BASE_BRANCH, str(self.remote), str(without_tags)) + with self.assertRaisesRegex(RuntimeError, "Test git cat-file failed"): + git(without_tags, "cat-file", "-t", source) + self.platform = without_tags + self.api.pull_requests.return_value = [] + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + second_source), second_source) + newest = self.root / "newest" + git(self.root, "clone", "--no-local", str(self.remote), str(newest)) + self.assertEqual(git(newest, "cat-file", "-t", source), "commit") + self.assertEqual(git(newest, "cat-file", "-t", second_source), "commit") + + def test_conflicting_source_tag_is_never_rewritten(self): + source = self.unreachable_source("released-source") + self.manifest["platform_sha"] = source + self.commit = self.save() + wrong = git(self.remote, "rev-parse", worker.BASE_BRANCH) + git(self.remote, "update-ref", worker.SOURCE_TAG_PREFIX + source, wrong) + with self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + source), wrong) + self.api.request.assert_not_called() + + def test_source_tag_retry_reconciles_a_concurrent_matching_creation(self): + commit = self.manifest["platform_sha"] + ref = worker.SOURCE_TAG_PREFIX + commit + with mock.patch.object(worker, "git", side_effect=["", worker.ReleaseError("race"), f"{commit}\t{ref}"]): + worker.retain_source(self.platform, commit, worker.git_environment("synthetic")) + def test_closed_unmerged_pr_requires_intervention(self): self.api.pull_requests.return_value = [{"state": "closed", "merged_at": None}] with self.assertRaisesRegex(worker.ReleaseError, "closed without merging"): From fe0da99828149ad079f9c26b6f6a31c30da68052 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 08:45:04 +0200 Subject: [PATCH 04/18] fix(swift-sdk): validate source tags during freeze dry runs --- .editorconfig | 1 + AGENTS.md | 4 +- packages/swift-sdk/SCHEMA_RELEASES.md | 2 + .../scripts/freeze_appstore_release.py | 18 +++---- .../scripts/test_freeze_appstore_release.py | 48 ++++++++++++++++++- 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/.editorconfig b/.editorconfig index dff33a306c1..2e9dc07ba04 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,7 @@ end_of_line = lf [*.rs] indent_size = 4 +# Preserve the existing indentation of the Swift SDK Python scripts. [packages/swift-sdk/scripts/*.py] indent_size = 4 diff --git a/AGENTS.md b/AGENTS.md index 5bf44aeccff..8b235bc094a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,7 +78,9 @@ Platform uses data contracts to define application data schemas: - Run linters: `yarn lint` ## Coding Style & Naming Conventions -- Editor config: 2-space indent (4 for `*.rs`), LF, UTF‑8, final newline (`.editorconfig`). +- Follow `.editorconfig`: 2-space indent by default; 4 spaces for `*.rs` and + `packages/swift-sdk/scripts/*.py`, preserving the existing Python script style. + Use LF, UTF‑8, and a final newline. - JS/TS: ESLint (Airbnb/TypeScript rules via package configs). Use camelCase for variables/functions, PascalCase for classes; prefer kebab-case filenames within JS packages. - Rust: Follow rustfmt defaults; keep code clippy-clean. Modules `snake_case`, types `PascalCase`, constants `SCREAMING_SNAKE_CASE`. - Rust architecture rules live in The Dash Platform Book (`book/`). Read [book/src/contributing/coding-conventions.md](book/src/contributing/coding-conventions.md) before changing versioned behaviour, validation, errors, fees, or limits; it states each rule, why it exists, and links to the chapter with the mechanics. Key rules: shipped `vN` modules are frozen and new behaviour is a new `vN` selected only by the unreleased protocol version's tables; numbers go in `SystemLimits`, fees in named `FEE_VERSION*` schedules; `platform_version` is the last parameter; no `unwrap`/`expect` on block-execution paths (a panic halts the chain); imports at the top, no inline `crate::` paths; latest-generation tests use `PlatformVersion::latest()`. diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index f790681f17b..696e04e8dce 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -68,6 +68,8 @@ all required releases, not merely a commit from before the snapshot merge. on `schema-release-data`. Select `dry_run` to validate and generate the patch without committing, creating source tags, pushing or creating a PR. Dry runs still require read credentials and query GitHub. + Existing source tags are validated in dry runs too, including for already + merged releases; missing tags are allowed and are not created. Do not use the Platform workflow to bypass App Store publication: it requires a published-state proof written by the trusted iOS monitor. Protect the data diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index 875f6df9d2d..b8744a807f1 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -255,8 +255,8 @@ def fetch_sources(clone, commits, env): raise ReleaseError("A registered source SHA does not identify a commit") -def retain_source(clone, commit, env): - """Publish a lightweight immutable source tag, reconciling concurrent retries.""" +def retain_source(clone, commit, env, *, dry_run=False): + """Validate an existing source tag; create a missing tag only in write mode.""" require_string(commit, SHA, "source commit") ref = SOURCE_TAG_PREFIX + commit @@ -268,7 +268,7 @@ def existing(): raise ReleaseError(f"Source retention tag points to a different object: {ref}") return True - if existing(): + if existing() or dry_run: return try: git(clone, "push", "origin", f"{commit}:{ref}", env=env) @@ -334,9 +334,8 @@ def generate(*args): commits = source_commits(merged_registry) | {manifest["platform_sha"]} fetch_sources(clone, commits, env) generate("--check") - if not dry_run: - for commit in sorted(commits): - retain_source(clone, commit, env) + for commit in sorted(commits): + retain_source(clone, commit, env, dry_run=dry_run) print("This release is already present in the merged registry.") return remote_branch = git(clone, "ls-remote", "--heads", "origin", branch, env=env) @@ -382,12 +381,13 @@ def generate(*args): raise ReleaseError("The generator changed files outside the snapshot allowlist") print(json.dumps({"branch": branch, "release_id": release_id, "files": sorted(changed), "dry_run": dry_run}, indent=2)) - if dry_run: - return # The source objects must remain reachable in a fresh checkout before # the PR references them. Neither tags nor source history are rewritten. + # Dry runs verify existing references too, but never create missing tags. for commit in sorted(source_commits(registry) | {manifest["platform_sha"]}): - retain_source(clone, commit, env) + retain_source(clone, commit, env, dry_run=dry_run) + if dry_run: + return if changed: git(clone, "add", "--", *sorted(changed)) staged = git(clone, "diff", "--cached", "--name-only") diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index 7d2ebba8c5b..d3c9292ad24 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -407,11 +407,33 @@ def test_conflicting_source_tag_is_never_rewritten(self): self.commit = self.save() wrong = git(self.remote, "rev-parse", worker.BASE_BRANCH) git(self.remote, "update-ref", worker.SOURCE_TAG_PREFIX + source, wrong) - with self.assertRaisesRegex(worker.ReleaseError, "different object"): - self.prepare() + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) self.assertEqual(git(self.remote, "rev-parse", worker.SOURCE_TAG_PREFIX + source), wrong) self.api.request.assert_not_called() + def test_annotated_source_tag_is_rejected_even_when_it_peels_to_the_named_commit(self): + source = self.manifest["platform_sha"] + tag = worker.SOURCE_TAG_PREFIX.removeprefix("refs/tags/") + source + git(self.platform, "tag", "--no-sign", "-a", tag, "-m", "annotated, not a direct commit ref", source) + git(self.platform, "push", str(self.remote), f"refs/tags/{tag}") + self.assertEqual(git(self.remote, "rev-parse", f"refs/tags/{tag}^{{commit}}"), source) + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_valid_source_tag_passes_dry_run_without_writes(self): + source = self.manifest["platform_sha"] + git(self.remote, "update-ref", worker.SOURCE_TAG_PREFIX + source, source) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + def test_source_tag_retry_reconciles_a_concurrent_matching_creation(self): commit = self.manifest["platform_sha"] ref = worker.SOURCE_TAG_PREFIX + commit @@ -431,7 +453,29 @@ def test_already_merged_release_creates_no_commit_or_pr(self): self.api.pull_requests.return_value = [{"state": "closed", "merged_at": "2026-09-18"}] self.api.request.reset_mock() before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run): + self.prepare(dry_run=dry_run) + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + + def test_already_merged_release_checks_source_tags_without_creating_missing_tags_in_dry_run(self): self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + merged_commit = git(self.remote, "rev-parse", branch) + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", merged_commit) + self.api.pull_requests.return_value = [{"state": "closed", "merged_at": "2026-09-18"}] + self.api.request.reset_mock() + ref = worker.SOURCE_TAG_PREFIX + self.manifest["platform_sha"] + git(self.remote, "update-ref", "-d", ref) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + git(self.remote, "update-ref", ref, merged_commit) + before = git(self.remote, "show-ref") + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) self.assertEqual(git(self.remote, "show-ref"), before) self.api.request.assert_not_called() From 7de890a810bc978b34252ece5bd405ddbfce0948 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 10:48:43 +0200 Subject: [PATCH 05/18] fix(swift-sdk): bridge legacy unversioned stores into V2 --- .github/workflows/tests.yml | 3 + packages/swift-sdk/SCHEMA_RELEASES.md | 54 ++- .../Persistence/DashLegacySchemaBridge.swift | 267 +++++++++++++++ .../Persistence/DashLegacyStoreSQLite.swift | 256 ++++++++++++++ .../Persistence/DashModelContainer.swift | 20 +- .../DashLegacySchemaMigrationTests.swift | 313 ++++++++++++++++++ .../SchemaStores/legacy-fd8d8d13e5/README.md | 66 ++++ .../legacy-fd8d8d13e5/fixture.store | Bin 0 -> 647168 bytes .../legacy-fd8d8d13e5/manifest.json | 291 ++++++++++++++++ .../DashHistoricalFixtureCaptureTests.swift | 79 +++++ .../swift-sdk/scripts/freeze_schema_models.py | 8 +- .../scripts/historical_schema_fixture.py | 138 ++++++++ .../scripts/test_historical_schema_fixture.py | 109 ++++++ 13 files changed, 1593 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json create mode 100644 packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift create mode 100644 packages/swift-sdk/scripts/historical_schema_fixture.py create mode 100644 packages/swift-sdk/scripts/test_historical_schema_fixture.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e7a854c733..ef36f1b9c79 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -504,6 +504,9 @@ jobs: - name: Check the frozen SwiftData models match FREEZES run: python3 packages/swift-sdk/scripts/freeze_schema_models.py --check + - name: Verify the historical migration fixture and pinned sources + run: python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check + - name: Test the freeze generator run: python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' -v diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 696e04e8dce..f249b5fca34 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -4,7 +4,58 @@ SwiftData schemas become supported history when a build reaches App Store distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. V1 is the agreed existing baseline. Intermediate pre-release V2–V5 schemas have been collapsed -into the working V2, including the public-key usage-limit columns; databases from those old development builds are unsupported. +into the working V2, including the public-key usage-limit columns. A separate +compatibility bridge handles older, unregistered `1.0.0` database layouts when +they can migrate without losing existing data. Other intermediate development +databases are unsupported. + +## Legacy stores before the release registry + +Frozen V1 and its fixture remain unchanged. They describe the accepted baseline, +but do not establish the exact schema shipped by the first App Store binary. +Some older app sources created an unversioned `Schema(modelTypes)` and omitted +the explicit migration plan; those databases still report version `1.0.0`. + +The shared `DashModelContainer` factory recognizes registered schema +fingerprints before opening an existing store. Unknown legacy `1.0.0` stores +may use the compatibility bridge: take a consistent backup including committed +WAL data, migrate an isolated copy automatically to `DashSchemaV2`, verify that +existing stored values and relationship rows survived, and reopen it through +the ordinary migration plan before installing it. The backup is retained for +recovery. Corruption, removed fields/entities, changed stored data, and newer +unknown schema versions are errors; this is not a general retry for any +container failure and never resets the store. + +Applications with their own database paths must use +`DashModelContainer.create(url:)` before opening the store elsewhere. Direct +`ModelContainer` construction bypasses this compatibility bridge. The iOS host +uses the shared factory while retaining its existing store path and lifecycle. +The bridge is for local stores; CloudKit and in-memory containers continue to +use their ordinary migration plan. + +Recovery files live beside the original store under +`.legacy-v2-backups/`. A successful bridge retains an +`original.store` backup. The `active.json` journal records an interrupted +installation; the next open reconciles it before exposing a container. These +are local wallet data, protected like the original store and excluded from +device backup. Do not upload them as release fixtures or edit the recovery +journal to bypass a failure. + +The historical regression fixture reconstructs Platform +`fd8d8d13e5d7cea17b00df5974934ab1910e8039` from the same checkout pair as iOS +`8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6` in +[Actions run 32706880873](https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873). +It contains synthetic records, not user data or an extracted App Store store. +The run failed before upload, so this provenance establishes a tested source +layout rather than proof of publication. Regression tests exercise the public +factory, data/default preservation, writes, reopen, and failure recovery. + +Keep the bridge for installations that skip the V2 app release. When advancing +to V3, bind `DashSchemaV2` to its released snapshot and retain the legacy-to-V2 +step before the normal V2-to-current plan. The bridge must never automatically +follow the latest live model graph. The release observer's one-time `bootstrap` +only records its observation baseline; it neither runs this migration nor +proves V1's App Store provenance. ## Release flow @@ -126,6 +177,7 @@ Run the automation tests with: ```sh python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' python3 packages/swift-sdk/scripts/freeze_schema_models.py --check +python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check ``` Swift SDK CI additionally checks the generated schemas against the released diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift new file mode 100644 index 00000000000..78ad2800387 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -0,0 +1,267 @@ +import CoreData +import Darwin +import Foundation +import SwiftData + +/// One-time, local-store bridge into the fixed V2 schema. The ordinary migration +/// plan owns recognized versions. This path accepts only an older 1.0.0 graph +/// whose complete existing SQLite data survives an inferred migration unchanged. +/// Call before publishing any container/context for the same URL. +enum DashLegacySchemaBridge { + typealias SQLite = DashLegacyStoreSQLite + enum Phase { case afterSnapshot, afterMigration, beforeInstall, writeLocked, afterCommit } + struct Hooks { + var visit: (Phase, URL) throws -> Void = { _, _ in } + } + struct Identity: Codable, Equatable { + let versions: [String] + let checksum: String + let hashes: [String: Data] + } + private struct Journal: Codable { + let formatVersion: Int + let operation: UUID + let source: Identity + let destination: Identity + } + + static func open(configuration: ModelConfiguration, schema: Schema, + plan: any SchemaMigrationPlan.Type, hooks: Hooks = Hooks()) throws -> ModelContainer { + let url = configuration.url + func ordinary() throws -> ModelContainer { + try ModelContainer(for: schema, migrationPlan: plan, configurations: [configuration]) + } + guard !configuration.isStoredInMemoryOnly else { return try ordinary() } + guard FileManager.default.fileExists(atPath: url.path) else { + if FileManager.default.fileExists(atPath: backupDirectory(for: url).appendingPathComponent("active.json").path) { + throw SQLite.Failure.unsupported("Original database is missing while migration recovery is pending") + } + return try ordinary() + } + let lock = try StoreLock(url: url) + defer { lock.close() } + let root = backupDirectory(for: url) + try SQLite.recoverRollbackJournal(at: url) + try recoverIfNeeded(at: url, root: root) + let source = try identity(at: url) + // Newer/unknown identifiers are never downgraded or treated as legacy. + guard source.versions == ["1.0.0"] else { return try ordinary() } + for registered in plan.schemas where version(registered.versionIdentifier) == "1.0.0" { + if source == (try identity(for: registered)) { return try ordinary() } + } + guard configuration.allowsSave else { throw SQLite.Failure.unsupported("The store is read-only") } + let permitted = Set(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)) + let required: Set = ["PersistentWallet", "PersistentAccount", "PersistentTransaction", "PersistentTxo"] + let names = Set(source.hashes.keys) + guard names.isSubset(of: permitted), required.isSubset(of: names) else { + throw SQLite.Failure.unsupported("The old model contains unsupported or missing entities") + } + try rejectExternalStorage(at: url) + let operation = UUID() + let directory = root.appendingPathComponent(operation.uuidString, isDirectory: true) + var directoryAttributes = try protectionAttributes(like: url) + directoryAttributes[.posixPermissions] = 0o700 + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, + attributes: directoryAttributes) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, + attributes: directoryAttributes) + var rootValues = URLResourceValues() + rootValues.isExcludedFromBackup = true + var excludedRoot = root + try excludedRoot.setResourceValues(rootValues) + let backup = directory.appendingPathComponent("original.store") + let candidate = directory.appendingPathComponent("candidate.store") + var committed = false + defer { + if !committed && !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) { + try? FileManager.default.removeItem(at: directory) + } + } + let rawSource = try SQLite.rawDigest(url) + try createProtectedFile(backup, like: url) + try SQLite.copy(from: url, to: backup) + guard try SQLite.rawDigest(url) == rawSource else { throw SQLite.Failure.sourceChanged } + try SQLite.integrityCheck(backup) + guard try identity(at: backup) == source else { throw SQLite.Failure.sourceChanged } + try hooks.visit(.afterSnapshot, url) + try createProtectedFile(candidate, like: url) + try SQLite.copy(from: backup, to: candidate) + // Never replace this with `schema` or the latest version. Users can skip + // the V2 app; later releases must keep V2's frozen graph as this target. + try autoreleasepool { + let bridgeSchema = Schema(versionedSchema: DashSchemaV2.self) + _ = try ModelContainer(for: bridgeSchema, configurations: [ + ModelConfiguration(schema: bridgeSchema, url: candidate, cloudKitDatabase: .none) + ]) + } + try SQLite.checkpoint(candidate) + try hooks.visit(.afterMigration, candidate) + try rejectExternalStorage(at: candidate) + try SQLite.integrityCheck(candidate) + try SQLite.validatePreservation(from: backup, to: candidate) + // The normal plan must accept the fixed V2 result. In a future release + // it may continue through additional explicitly registered stages here. + try autoreleasepool { + _ = try ModelContainer(for: schema, migrationPlan: plan, configurations: [ + ModelConfiguration(schema: schema, url: candidate, cloudKitDatabase: .none) + ]) + } + try SQLite.checkpoint(candidate) + try rejectExternalStorage(at: candidate) + try SQLite.integrityCheck(candidate) + let destination = try identity(at: candidate) + let journal = Journal(formatVersion: 1, operation: operation, source: source, destination: destination) + // Publish the marker only after both copy filenames and their data are + // durable. The marker lives in the parent directory, synced separately. + for fileURL in [backup, candidate] { + let file = try FileHandle(forWritingTo: fileURL) + defer { try? file.close() } + try file.synchronize() + } + try synchronizeDirectory(directory) + try writeJournal(journal, at: root) + try hooks.visit(.beforeInstall, url) + try SQLite.copy(from: candidate, to: url) { + // backup_step(0) owns SQLite's write lock, closing the race between + // comparison and commit. Main/WAL bytes must still be the snapshot's + // bytes; unrelated or concurrent writes cause a safe retry. + guard try SQLite.rawDigest(url) == rawSource else { throw SQLite.Failure.sourceChanged } + try hooks.visit(.writeLocked, url) + } + committed = true + try hooks.visit(.afterCommit, url) + let container = try ordinary() + // Clear before returning a live container. Recovery must never replace + // a successfully opened store after the app has started writing to it. + try clearJournal(at: root) + try? FileManager.default.removeItem(at: candidate) + return container + } + + static func backupDirectory(for url: URL) -> URL { + url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".legacy-v2-backups", isDirectory: true) + } + + private static func recoverIfNeeded(at url: URL, root: URL) throws { + let marker = root.appendingPathComponent("active.json") + guard FileManager.default.fileExists(atPath: marker.path) else { return } + let journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: marker)) + guard journal.formatVersion == 1 else { throw SQLite.Failure.unsupported("Unknown migration recovery format") } + let directory = root.appendingPathComponent(journal.operation.uuidString, isDirectory: true) + let backup = directory.appendingPathComponent("original.store") + guard try identity(at: backup) == journal.source else { + throw SQLite.Failure.unsupported("Migration backup is missing or has changed") + } + // SQLite commits or rolls back the entire page replacement, including + // WAL recovery. We never restore a backup over potentially newer writes. + let current = try identity(at: url) + if current == journal.destination { + let candidate = directory.appendingPathComponent("candidate.store") + guard try identity(at: candidate) == journal.destination else { + throw SQLite.Failure.unsupported("Validated migration candidate is missing or has changed") + } + try SQLite.integrityCheck(url) + // Explicit later stages may intentionally transform legacy values. + // Compare with the already validated final candidate, not the old graph. + try SQLite.validatePreservation(from: candidate, to: url) + } else if current != journal.source { + throw SQLite.Failure.unsupported("Store changed while migration recovery was pending") + } + try clearJournal(at: root) + if current == journal.source { + // Promotion never committed; the original is still authoritative. + try? FileManager.default.removeItem(at: directory) + } else { + try? FileManager.default.removeItem(at: directory.appendingPathComponent("candidate.store")) + } + } + + private static func identity(at url: URL) throws -> Identity { + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + guard let versions = metadata[NSStoreModelVersionIdentifiersKey] as? [String], + let checksum = metadata["NSStoreModelVersionChecksumKey"] as? String, + let hashes = metadata[NSStoreModelVersionHashesKey] as? [String: Data], + !versions.isEmpty, !checksum.isEmpty, !hashes.isEmpty else { + throw SQLite.Failure.unsupported("Missing Core Data schema metadata") + } + return Identity(versions: versions, checksum: checksum, hashes: hashes) + } + + private static func identity(for type: any VersionedSchema.Type) throws -> Identity { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("schema.store") + try autoreleasepool { + let schema = Schema(versionedSchema: type) + _ = try ModelContainer(for: schema, configurations: [ + ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + ]) + } + return try identity(at: url) + } + + private static func rejectExternalStorage(at url: URL) throws { + let parent = url.deletingLastPathComponent() + let names = Set([url.lastPathComponent, url.deletingPathExtension().lastPathComponent]) + for name in names { + for companion in [".\(name)_SUPPORT", "\(name)_SUPPORT", "\(name).support", ".\(name).support"] { + if FileManager.default.fileExists(atPath: parent.appendingPathComponent(companion).path) { + throw SQLite.Failure.unsupported("External binary storage requires a separate migration") + } + } + } + } + + private static func protectionAttributes(like original: URL) throws -> [FileAttributeKey: Any] { + let attributes = try FileManager.default.attributesOfItem(atPath: original.path) + var retained: [FileAttributeKey: Any] = [.posixPermissions: attributes[.posixPermissions] ?? 0o600] + if let protection = attributes[.protectionKey] { retained[.protectionKey] = protection } + return retained + } + private static func createProtectedFile(_ url: URL, like original: URL) throws { + guard FileManager.default.createFile(atPath: url.path, contents: Data(), + attributes: try protectionAttributes(like: original)) else { + throw SQLite.Failure.database("Cannot create protected migration copy") + } + } + + private static func writeJournal(_ journal: Journal, at root: URL) throws { + let url = root.appendingPathComponent("active.json") + try JSONEncoder().encode(journal).write(to: url, options: .atomic) + let file = try FileHandle(forWritingTo: url) + defer { try? file.close() } + try file.synchronize() + try synchronizeDirectory(root) + } + private static func clearJournal(at root: URL) throws { + try FileManager.default.removeItem(at: root.appendingPathComponent("active.json")) + try synchronizeDirectory(root) + } + private static func synchronizeDirectory(_ url: URL) throws { + let descriptor = Darwin.open(url.path, O_RDONLY) + guard descriptor >= 0 else { throw SQLite.Failure.database("Cannot open migration directory") } + defer { Darwin.close(descriptor) } + guard fsync(descriptor) == 0 else { throw SQLite.Failure.database("Cannot persist migration journal") } + } + private static func version(_ value: Schema.Version) -> String { + "\(value.major).\(value.minor).\(value.patch)" + } + + private final class StoreLock { + private var descriptor: Int32 + init(url: URL) throws { + descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR | O_NOFOLLOW, 0o600) + guard descriptor >= 0 else { throw SQLite.Failure.database("Cannot open database migration lock") } + guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { + Darwin.close(descriptor) + descriptor = -1 + throw SQLite.Failure.database("Another process is opening this database; retry after it finishes") + } + } + func close() { + if descriptor >= 0 { flock(descriptor, LOCK_UN); Darwin.close(descriptor); descriptor = -1 } + } + deinit { close() } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift new file mode 100644 index 00000000000..3ff407965e0 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -0,0 +1,256 @@ +import CryptoKit +import Foundation +import SQLite3 + +/// SQLite operations used only before a legacy store is opened by SwiftData. +/// The backup API copies committed WAL content and promotes a candidate in one +/// SQLite transaction; it never renames or deletes journal sidecars. +enum DashLegacyStoreSQLite { + enum Failure: Error, LocalizedError { + case database(String) + case unsupported(String) + case sourceChanged + + var errorDescription: String? { + switch self { + case .database(let reason): return "Legacy database migration failed: \(reason)" + case .unsupported(let reason): return "Legacy database migration is not safe: \(reason)" + case .sourceChanged: return "The database changed during migration. Close other users of the store and retry." + } + } + } + + final class Connection { + let handle: OpaquePointer + init(_ url: URL, writable: Bool, create: Bool = false) throws { + var result: OpaquePointer? + let flags = writable ? SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0) : SQLITE_OPEN_READONLY + let status = sqlite3_open_v2(url.path, &result, flags | SQLITE_OPEN_FULLMUTEX, nil) + guard status == SQLITE_OK, let result else { + let reason = result.map { String(cString: sqlite3_errmsg($0)) } ?? "Cannot open SQLite store" + sqlite3_close(result) + throw Failure.database(reason) + } + handle = result + sqlite3_busy_timeout(handle, 0) + } + deinit { sqlite3_close(handle) } + func execute(_ sql: String) throws { + guard sqlite3_exec(handle, sql, nil, nil, nil) == SQLITE_OK else { + throw Failure.database(String(cString: sqlite3_errmsg(handle))) + } + } + func query(_ sql: String, _ row: (OpaquePointer) throws -> Void) throws { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw Failure.database(String(cString: sqlite3_errmsg(handle))) + } + defer { sqlite3_finalize(statement) } + var status = sqlite3_step(statement) + while status == SQLITE_ROW { + try row(statement) + status = sqlite3_step(statement) + } + guard status == SQLITE_DONE else { throw Failure.database(String(cString: sqlite3_errmsg(handle))) } + } + } + + static func rawDigest(_ url: URL) throws -> [String: String] { + var result: [String: String] = [:] + for suffix in ["", "-wal"] { + let path = URL(fileURLWithPath: url.path + suffix) + guard FileManager.default.fileExists(atPath: path.path) else { continue } + let file = try FileHandle(forReadingFrom: path) + defer { try? file.close() } + var digest = SHA256() + while let chunk = try file.read(upToCount: 1_048_576), !chunk.isEmpty { digest.update(data: chunk) } + result[suffix] = hex(digest.finalize()) + } + guard result[""] != nil else { throw Failure.database("Store file is missing") } + return result + } + + static func copy(from source: URL, to destination: URL, + lockedDestinationCheck: (() throws -> Void)? = nil) throws { + let input = try Connection(source, writable: false) + let output = try Connection(destination, writable: true, create: lockedDestinationCheck == nil) + // C backup handles borrow both Swift connection owners, including on errors. + try withExtendedLifetime((input, output)) { + guard let backup = sqlite3_backup_init(output.handle, "main", input.handle, "main") else { + throw Failure.database(String(cString: sqlite3_errmsg(output.handle))) + } + var finished = false + defer { if !finished { sqlite3_backup_finish(backup) } } + // Zero pages still acquires the destination write lock. No destination + // connection APIs may run until backup_finish; raw file reads are safe. + guard sqlite3_backup_step(backup, 0) == SQLITE_OK else { + throw Failure.database("Database is busy; cannot acquire migration write lock") + } + try lockedDestinationCheck?() + guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { + throw Failure.database("SQLite could not complete the transactional database copy") + } + let status = sqlite3_backup_finish(backup) + finished = true + guard status == SQLITE_OK else { throw Failure.database("SQLite could not commit the database copy") } + } + } + + static func recoverRollbackJournal(at url: URL) throws { + guard FileManager.default.fileExists(atPath: url.path + "-journal") else { return } + // A process killed during SQLite promotion can leave a hot rollback + // journal. A writable SQLite read recovers it before metadata inspection. + let connection = try Connection(url, writable: true) + try connection.query("PRAGMA schema_version") { _ in } + } + + static func integrityCheck(_ url: URL) throws { + let connection = try Connection(url, writable: false) + var answers: [String] = [] + try connection.query("PRAGMA quick_check") { answers.append(string($0, 0)) } + guard answers == ["ok"] else { throw Failure.unsupported("SQLite integrity check failed") } + } + + static func checkpoint(_ url: URL) throws { + let connection = try Connection(url, writable: true) + guard sqlite3_wal_checkpoint_v2(connection.handle, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK else { + throw Failure.database("Cannot close the migrated WAL") + } + try connection.execute("PRAGMA journal_mode=DELETE") + } + + struct Column: Equatable { + let actual: String + let normalized: String + let declaredType: String + } + struct Table { + let actual: String + let columns: [String: Column] + } + struct Layout { + let entities: [Int64: String] + let tables: [String: Table] + } + + /// Compare every original application column and typed cell, including + /// relationship foreign keys/join rows. Extra destination columns/tables + /// are allowed; removals, type conversions and changed values are not. + static func validatePreservation(from source: URL, to destination: URL) throws { + let old = try Connection(source, writable: false) + let new = try Connection(destination, writable: false) + let before = try layout(old) + let after = try layout(new) + guard Set(before.entities.values).isSubset(of: Set(after.entities.values)) else { + throw Failure.unsupported("Migration removed an entity") + } + for (name, table) in before.tables { + guard let next = after.tables[name] else { throw Failure.unsupported("Migration removed table \(name)") } + let names = table.columns.keys.sorted() + for name in names { + guard let nextColumn = next.columns[name], nextColumn.declaredType == table.columns[name]?.declaredType else { + throw Failure.unsupported("Migration removed or converted a column in \(table.actual)") + } + } + let oldDigest = try rowsDigest(old, table: table, names: names, entities: before.entities) + let newDigest = try rowsDigest(new, table: next, names: names, entities: after.entities) + guard oldDigest == newDigest else { throw Failure.unsupported("Migration changed existing data in \(name)") } + } + } + + private static func layout(_ connection: Connection) throws -> Layout { + var entities: [Int64: String] = [:] + try connection.query("SELECT Z_ENT, Z_NAME FROM Z_PRIMARYKEY") { + entities[sqlite3_column_int64($0, 0)] = string($0, 1).uppercased() + } + guard !entities.isEmpty else { throw Failure.unsupported("Missing Core Data entity map") } + // Core Data metadata and persistent-history bookkeeping are not model + // rows. SwiftData may rebuild them while inferring a lightweight map. + let internalTables: Set = ["Z_METADATA", "Z_MODELCACHE", "Z_PRIMARYKEY", "ACHANGE", "ATRANSACTION", "ATRANSACTIONSTRING"] + var tableNames: [String] = [] + try connection.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") { + let name = string($0, 0) + if !internalTables.contains(name) { tableNames.append(name) } + } + var tables: [String: Table] = [:] + for tableName in tableNames { + let normalized = try normalize(tableName, entities: entities) + guard tables[normalized] == nil else { throw Failure.unsupported("Ambiguous Core Data table name") } + var columns: [String: Column] = [:] + try connection.query("PRAGMA table_info(\(quote(tableName)))") { + let name = string($0, 1) + // Optimistic-lock revision changes are not application values. + guard name != "Z_OPT" else { return } + let key = try normalize(name, entities: entities) + guard columns[key] == nil else { throw Failure.unsupported("Ambiguous Core Data column name") } + columns[key] = Column(actual: name, normalized: key, declaredType: string($0, 2).uppercased()) + } + tables[normalized] = Table(actual: tableName, columns: columns) + } + return Layout(entities: entities, tables: tables) + } + + private static func normalize(_ name: String, entities: [Int64: String]) throws -> String { + guard name.hasPrefix("Z_") else { return name } + let suffix = name.dropFirst(2) + let digits = suffix.prefix(while: { $0.isNumber }) + guard !digits.isEmpty else { return name } + guard let number = Int64(digits), let entity = entities[number] else { + throw Failure.unsupported("Unknown entity ordinal in relationship table") + } + return "Z_" + entity + "_" + suffix.dropFirst(digits.count) + } + + private static func rowsDigest(_ connection: Connection, table: Table, names: [String], + entities: [Int64: String]) throws -> String { + let columns = names.compactMap { table.columns[$0] } + let selected = columns.map { quote($0.actual) }.joined(separator: ",") + let ordering = table.columns["Z_PK"].map { quote($0.actual) } ?? selected + var digest = SHA256() + var count: UInt64 = 0 + try connection.query("SELECT \(selected) FROM \(quote(table.actual)) ORDER BY \(ordering)") { row in + count += 1 + for (offset, column) in columns.enumerated() { + let index = Int32(offset) + let type = sqlite3_column_type(row, index) + digest.update(data: Data([UInt8(type)])) + var data = Data() + switch type { + case SQLITE_INTEGER: + let value = sqlite3_column_int64(row, index) + if column.actual == "Z_ENT" { + guard let entity = entities[value] else { throw Failure.unsupported("Unknown row entity ordinal") } + data = Data(entity.utf8) + } else { + var bits = value.bigEndian + data = withUnsafeBytes(of: &bits) { Data($0) } + } + case SQLITE_FLOAT: + var bits = sqlite3_column_double(row, index).bitPattern.bigEndian + data = withUnsafeBytes(of: &bits) { Data($0) } + case SQLITE_TEXT, SQLITE_BLOB: + let size = Int(sqlite3_column_bytes(row, index)) + let bytes = type == SQLITE_TEXT ? sqlite3_column_text(row, index).map(UnsafeRawPointer.init) : sqlite3_column_blob(row, index) + if size > 0, let bytes { data = Data(bytes: bytes, count: size) } + case SQLITE_NULL: break + default: throw Failure.unsupported("Unknown SQLite value type") + } + var length = UInt64(data.count).bigEndian + digest.update(data: withUnsafeBytes(of: &length) { Data($0) }) + digest.update(data: data) + } + } + var rows = count.bigEndian + digest.update(data: withUnsafeBytes(of: &rows) { Data($0) }) + return hex(digest.finalize()) + } + + private static func quote(_ name: String) -> String { "\"" + name.replacingOccurrences(of: "\"", with: "\"\"") + "\"" } + private static func string(_ statement: OpaquePointer, _ column: Int32) -> String { + guard let text = sqlite3_column_text(statement, column) else { return "" } + return String(decoding: UnsafeBufferPointer(start: text, count: Int(sqlite3_column_bytes(statement, column))), as: UTF8.self) + } + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ec1e51850e1..2065f1a37eb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -116,14 +116,14 @@ public enum DashModelContainer { groupContainer: groupContainer, cloudKitDatabase: cloudKit ? .automatic : .none ) - return try makeContainer(configuration: modelConfiguration) + return try makeContainer(configuration: modelConfiguration, bridgeLegacyStore: !cloudKit) } /// Open (or create) the store at an explicit file URL through the same /// schema and migration plan as `create(cloudKit:groupContainer:)`. The /// migration tests use it to open stores written by older builds exactly /// the way the app would. - static func create(url: URL) throws -> ModelContainer { + public static func create(url: URL) throws -> ModelContainer { let modelConfiguration = ModelConfiguration( schema: schema, url: url, @@ -139,11 +139,14 @@ public enum DashModelContainer { /// tested against, because it is the order under which a mixed /// live/frozen graph would rebind a released version's entities. private static func makeContainer( - configuration: ModelConfiguration + configuration: ModelConfiguration, + bridgeLegacyStore: Bool = true ) throws -> ModelContainer { - // Always wire the migration plan so stores created by an older SDK - // advance through the registered versioned schemas. - try ModelContainer( + if bridgeLegacyStore { + return try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: DashMigrationPlan.self) + } + return try ModelContainer( for: schema, migrationPlan: DashMigrationPlan.self, configurations: [configuration] @@ -181,8 +184,9 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Migration tests establish compatibility from this accepted baseline into /// the current live schema. They do not reconstruct or verify the database /// written by the original App Store binary. Other historical development -/// layouts are unsupported: opening an unrecognized store throws an error; -/// the container does not silently erase or recreate it. +/// layouts are accepted only by the local legacy bridge when every existing +/// value and relationship survives migration to fixed V2. Other layouts fail; +/// the container never erases or recreates a user's database. public enum DashSchemaV1: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(1, 0, 0) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift new file mode 100644 index 00000000000..6a615148c68 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -0,0 +1,313 @@ +import CoreData +import Foundation +import SQLite3 +import SwiftData +import XCTest +@testable import SwiftDashSDK + +@Model +private final class BridgeFutureMarker { + var value: String = "future" + init() {} +} +private enum BridgeFutureV3: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV2.models + [BridgeFutureMarker.self] } +} +private enum BridgeFuturePlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV2.self, BridgeFutureV3.self] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self), + .custom(fromVersion: DashSchemaV2.self, toVersion: BridgeFutureV3.self, + willMigrate: { context in + for wallet in try context.fetch(FetchDescriptor()) { + wallet.name = "explicit future transformation" + } + try context.save() + }, didMigrate: nil)] + } +} + +@MainActor +final class DashLegacySchemaMigrationTests: XCTestCase { + private enum Injected: Error { case stop } + + private func withStore(baseline: Bool = false, _ body: (URL) throws -> Void) throws { + let source = try XCTUnwrap(Bundle.module.url( + forResource: baseline ? "dash-v1" : "fixture", withExtension: "store", + subdirectory: baseline ? "Fixtures/SchemaStores" : "Fixtures/SchemaStores/legacy-fd8d8d13e5")) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("DashModel.store") + try FileManager.default.copyItem(at: source, to: url) + try autoreleasepool { try body(url) } + } + private func open(_ url: URL, hooks: DashLegacySchemaBridge.Hooks = .init()) throws -> ModelContainer { + let schema = DashModelContainer.schema + return try DashLegacySchemaBridge.open( + configuration: ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none), + schema: schema, plan: DashMigrationPlan.self, hooks: hooks) + } + private func operationDirectories(_ url: URL) throws -> [URL] { + let root = DashLegacySchemaBridge.backupDirectory(for: url) + guard FileManager.default.fileExists(atPath: root.path) else { return [] } + return try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.isDirectoryKey]) + .filter { (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true } + } + private func verifyRows(_ context: ModelContext, walletName: String = "historical audit wallet") throws { + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 1) + let wallet = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let contract = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let type = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + let index = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(wallet.walletId, Data(repeating: 0x61, count: 32)) + XCTAssertEqual(wallet.name, walletName) + XCTAssertEqual(wallet.network, .testnet) + XCTAssertEqual(contract.id, Data(repeating: 0x62, count: 32)) + XCTAssertEqual(contract.serializedContract, Data("{}".utf8)) + XCTAssertEqual(type.dataContract?.persistentModelID, contract.persistentModelID) + XCTAssertEqual(index.documentType?.persistentModelID, type.persistentModelID) + XCTAssertEqual(contract.documentTypes?.count, 1) + XCTAssertEqual(type.indices?.count, 1) + XCTAssertEqual(index.properties, ["name"]) + XCTAssertFalse(type.indexOnly) + XCTAssertNil(index.countable) + XCTAssertNil(index.summable) + XCTAssertNil(index.averageable) + XCTAssertNil(index.terminal) + XCTAssertNil(index.timeRangeJSON) + XCTAssertEqual([index.rangeCountable, index.rangeSummable, index.rangeAverageable, + index.rankedCountable, index.rankedSummable, index.rankedAverageable, + index.preallocated], Array(repeating: false, count: 7)) + } + + func testAcceptedV1UsesNormalPlanWithoutBridge() throws { + try withStore(baseline: true) { url in + _ = try open(url, hooks: .init(visit: { _, _ in XCTFail("Known V1 must not bridge") })) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + _ = try DashModelContainer.createInMemory() + } + + func testHistoricalStorePreservesDataDefaultsBackupAndDoesNotBridgeAgain() throws { + try withStore { url in + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()).first) + wallet.name = "saved after migration" + try container.mainContext.save() + } + let operations = try operationDirectories(url) + XCTAssertEqual(operations.count, 1) + let backup = try XCTUnwrap(operations.first).appendingPathComponent("original.store") + XCTAssertEqual(try DashSchemaFixtureSupport.describeStore(at: backup, version: Schema.Version(1, 0, 0)).model_checksum, + "wOm/tD2jkxoKsyP7GFXVNeebjqpLZbKZZ4EqYLkwlMk=") + let reopened = try open(url, hooks: .init(visit: { _, _ in XCTFail("Completed bridge must not repeat") })) + try verifyRows(reopened.mainContext, walletName: "saved after migration") + XCTAssertEqual(try operationDirectories(url).count, 1) + } + } + + func testCommittedWALDataIsIncludedAndConcurrentWriterIsLockedOutAtPromotion() throws { + try withStore { url in + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute("PRAGMA journal_mode=WAL") + try connection.execute("PRAGMA wal_autocheckpoint=0") + try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='committed in WAL'") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path + "-wal")) + var checkedLock = false + let migrated = try open(url, hooks: .init(visit: { phase, _ in + if phase == .writeLocked { + XCTAssertThrowsError(try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='racing writer'")) + checkedLock = true + } + })) + XCTAssertTrue(checkedLock) + try verifyRows(migrated.mainContext, walletName: "committed in WAL") + } + } + + func testClosedWALStoreMigratesOnColdOpen() throws { + try withStore { url in + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute("PRAGMA journal_mode=WAL") + try connection.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='closed WAL store'") + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + "-wal")) + let migrated = try open(url) + try verifyRows(migrated.mainContext, walletName: "closed WAL store") + } + } + + func testChangedSourceIsNotOverwrittenAndRetryPreservesNewWrite() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, source in + if phase == .afterSnapshot { + try DashLegacyStoreSQLite.Connection(source, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='new concurrent value'") + } + }))) + let migrated = try open(url) + try verifyRows(migrated.mainContext, walletName: "new concurrent value") + } + } + + func testFailedValidationAndPrecommitErrorLeaveOriginalUnchanged() throws { + try withStore { url in + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + try DashLegacyStoreSQLite.Connection(candidate, writable: true) + .execute("DELETE FROM ZPERSISTENTINDEX") + } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty, "Uncommitted copies must not accumulate on retries") + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + try DashLegacyStoreSQLite.Connection(candidate, writable: true) + .execute("UPDATE ZPERSISTENTINDEX SET ZDOCUMENTTYPE=NULL") + } + })), "A same-count relationship change must fail validation") + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .writeLocked { throw Injected.stop } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } + } + + func testAfterCommitInterruptionRecoversWithoutRepeatingMigration() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let marker = DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + let recovered = try open(url, hooks: .init(visit: { _, _ in XCTFail("Committed candidate should only recover") })) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertEqual(try operationDirectories(url).count, 1) + } + } + + func testPendingRecoveryWithMissingOriginalNeverCreatesEmptyDatabase() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .beforeInstall { throw Injected.stop } + }))) + let displaced = url.deletingLastPathComponent().appendingPathComponent("displaced.store") + try FileManager.default.moveItem(at: url, to: displaced) + XCTAssertThrowsError(try open(url)) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: displaced.path)) + } + } + + func testCorruptionExternalStorageAndNewerUnknownVersionDoNotBridge() throws { + try withStore { url in + try Data("not a database".utf8).write(to: url) + let bytes = try Data(contentsOf: url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try Data(contentsOf: url), bytes) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + let support = url.deletingLastPathComponent().appendingPathComponent("." + url.lastPathComponent + "_SUPPORT") + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + metadata[NSStoreModelVersionIdentifiersKey] = ["2.0.0"] + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + let copy = url.deletingLastPathComponent().appendingPathComponent("newer-original.store") + try DashLegacyStoreSQLite.copy(from: url, to: copy) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { _, _ in XCTFail("Newer unknown store must not bridge") }))) + // The ordinary SwiftData path may switch journal modes before + // rejecting an unknown version; application values must remain intact. + try DashLegacyStoreSQLite.validatePreservation(from: copy, to: url) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testUnknownEntityAndCandidateExternalStorageAreRejected() throws { + try withStore { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + var hashes = try XCTUnwrap(metadata[NSStoreModelVersionHashesKey] as? [String: Data]) + hashes["UnknownEntity"] = Data(repeating: 1, count: 32) + metadata[NSStoreModelVersionHashesKey] = hashes + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + XCTAssertThrowsError(try open(url)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + try withStore { url in + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + let support = candidate.deletingLastPathComponent().appendingPathComponent("." + candidate.lastPathComponent + "_SUPPORT") + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + } + }))) + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testSkippingV2UsesFixedBridgeThenRegisteredCustomFutureStage() throws { + try withStore { url in + let schema = Schema(versionedSchema: BridgeFutureV3.self) + var checkedV2 = false + let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) + XCTAssertThrowsError(try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: BridgeFuturePlan.self, + hooks: .init(visit: { phase, candidate in + if phase == .afterMigration { + _ = try DashSchemaFixtureSupport.describeStore(at: candidate, version: Schema.Version(2, 0, 0)) + checkedV2 = true + } + if phase == .afterCommit { throw Injected.stop } + }))) + XCTAssertTrue(checkedV2) + let recovered = try DashLegacySchemaBridge.open( + configuration: configuration, schema: schema, plan: BridgeFuturePlan.self) + try verifyRows(recovered.mainContext, walletName: "explicit future transformation") + XCTAssertEqual(try recovered.mainContext.fetchCount(FetchDescriptor()), 0) + } + } + + func testValidatorRejectsColumnTypeChangesAndPreservesTypedValues() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let original = directory.appendingPathComponent("source.store") + let candidate = directory.appendingPathComponent("candidate.store") + let source = try DashLegacyStoreSQLite.Connection(original, writable: true, create: true) + try source.execute("CREATE TABLE Z_PRIMARYKEY(Z_ENT INTEGER,Z_NAME TEXT)") + try source.execute("INSERT INTO Z_PRIMARYKEY VALUES(1,'PersistentWallet')") + try source.execute("CREATE TABLE ZPERSISTENTWALLET(Z_PK INTEGER,Z_ENT INTEGER,Z_OPT INTEGER,V BLOB,T TEXT,N INTEGER)") + try source.execute("INSERT INTO ZPERSISTENTWALLET VALUES(1,1,1,X'000100',CAST(X'610062' AS TEXT),9223372036854775807)") + try DashLegacyStoreSQLite.copy(from: original, to: candidate) + try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate) + let copy = try DashLegacyStoreSQLite.Connection(candidate, writable: true) + try copy.execute("UPDATE ZPERSISTENTWALLET SET T='a'") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Embedded NUL bytes must be compared") + try copy.execute("DROP TABLE ZPERSISTENTWALLET") + try copy.execute("CREATE TABLE ZPERSISTENTWALLET(Z_PK INTEGER,Z_ENT INTEGER,Z_OPT INTEGER,V TEXT,T TEXT,N INTEGER)") + try copy.execute("INSERT INTO ZPERSISTENTWALLET VALUES(1,1,1,X'000100',CAST(X'610062' AS TEXT),9223372036854775807)") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Declared storage type changes must be rejected even when cells agree") + try copy.execute("ALTER TABLE ZPERSISTENTWALLET DROP COLUMN T") + XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Removed original columns must fail validation") + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md new file mode 100644 index 00000000000..83ed2744bc6 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/README.md @@ -0,0 +1,66 @@ +# Historical source reconstruction + +`fixture.store` is a synthetic SQLite store reconstructed from Platform +`fd8d8d13e5d7cea17b00df5974934ab1910e8039`. The associated iOS source is +`8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6`, referenced by +[Actions run 32706880873](https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873). +This source pair is not verified App Store provenance. The fixture was created +on a simulator; it contains no user data and is not extracted from an app binary. + +The store contains one synthetic wallet, data contract, document type and index, +including the type-to-contract and index-to-type relationships. Its 34 entities +come from the historical factory's literal `modelTypes` list. It was created +using an unversioned `Schema` and no migration plan, as in that historical app. +Its `PersistentDocumentType` and `PersistentIndex` hashes differ from the +accepted frozen V1. The accepted V1 definitions and `dash-v1.store` remain +unchanged and cover a separate migration regression. + +`manifest.json` records the exact source pair, toolchain, original fixture +checksum, schema metadata, SQLite indexes, synthetic row counts, source-file +digests, deterministic generated-file digests and capture recipe digest. The +recorded capture used Xcode 26.6 (17F113), an arm64 iPhone 17 simulator running +iOS 26.5, and Release configuration. The fixture SHA-256 is +`1cb3d6c2c299eb9afa9a2152e023acff6c108f1f4caebafc9db8da4a90fbd0da`. + +## Verify and reproduce + +From a full-history Platform checkout, verify the committed SQLite evidence and +regenerate all historical Swift source in memory: + +```sh +python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check +``` + +To recapture on macOS, first build the current SDK's simulator XCFramework as +described in the SDK build guide. Then export the historical models and their +dedicated capture test into a new temporary SDK directory: + +```sh +python3 packages/swift-sdk/scripts/historical_schema_fixture.py \ + --prepare-sdk /tmp/dash-historical-capture-sdk +cd /tmp/dash-historical-capture-sdk +xcodebuild test -scheme SwiftDashSDK -configuration Release \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5,arch=arm64' \ + -parallel-testing-enabled NO \ + -derivedDataPath /tmp/dash-historical-capture-derived \ + -resultBundlePath /tmp/dash-historical-capture.xcresult \ + -only-testing:SwiftDashSDKTests/DashHistoricalFixtureCaptureTests \ + CODE_SIGNING_ALLOWED=NO ARCHS=arm64 ENABLE_TESTABILITY=YES +xcrun xcresulttool export attachments \ + --path /tmp/dash-historical-capture.xcresult \ + --output-path /tmp/dash-historical-capture-attachments +``` + +Choose unused paths for another run. For comparison with this fixture, use the +recorded toolchain/runtime. The test compares the regenerated store's entity +hashes, model checksum, version identifier and indexes with the manifest before +attaching its standalone store and JSON metadata. SQLite UUIDs and timestamps +vary, so recaptured bytes are not expected to match the original file checksum. +Do not overwrite the committed fixture to make a migration or metadata check +pass; investigate differences first. + +The export uses the current SDK as a build harness, while every historical model +and stored value type comes from the pinned Platform source. Only the temporary +SDK receives these 36 generated Swift files and the capture test; the shipping +SDK does not gain a second historical model graph. The normal migration tests +consume the committed SQLite fixture directly. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store new file mode 100644 index 0000000000000000000000000000000000000000..944474f2fc663cd08a1bd9376bbeff238a474460 GIT binary patch literal 647168 zcmeF)30zI>{x|;JJh!7kgC+?Lnp32i=1HZI=27!p8c>QTX*4xx(tr#hl_^7mS)oFd zLTOS_lIp)Z_kEvk=XcKUJpc20Jv?{kvuvxi_qx`;*7tp_?CaCsFxzC{8SE31&a_a93fX=CoOxg~-0o9u5}^Iw0z`@&OoLlXiTkW4)CC#mp=T-;iRw;9z{}fB*y_009U<00Izz00jOnKtH#I_y7Oyfei>i00Izz00bZa0SG_<0uX?}LKMLJ z{|j+cQ4<6p009U<00Izz00bZa0SG|gM*-yjA3bmn0uX=z1Rwwb2tWV=5P$##Ag~Yx zkpCCrsG=qaKmY;|fB*y_009U<00Izzz>fmR|37-*9t0o&0SG_<0uX=z1Rwwb2tZ&V z3LyV4#8E{}5P$##AOHafKmY;|fB*y_0D&I`kpF-5z&!{+00Izz00bZa0SG_<0uX?} zLKHy$Ux=fMnjioH2tWV=5P$##AOHafKmY<;Smrc^;lY z!G3|B&W_#!jv+3d!2-^HzQKWx&cU`@o&L=d866po_y7NI4=kP^0uX=z1Rwwb2tWV= z5P$##An@x2@c#d=9|N2L1Rwwb2tWV=5P$##AOHaf{1*v?ka7I|{eN20M*wJOmKa#D z5Lg&~d&6JI`}eQGe|wkz-@LQ`Z+HLgN$~#vf6?`YQXv2V2tWV=5P$##AOHafK;XYx z0H6Q=uO5CB4*>{300Izz00bZa0SG_<0{}Q|00Izz00bZa z0SG_<0ucCx0-6|4oBGJ(cY; zU(ypU+lu%9f8n5`YY0F90uX=z1Rwwb2tWV=5P-l>52PyqS=7viI92tWV=5P$##AOHafKmY;|fWS`@K>q(p(C7vN5P$##AOHaf zKmY;|fB*y_@CyZy|9>Gqx`qG*AOHafKmY;|fB*y_009X6Bmw0Ap9GC=AOHafKmY;| zfB*y_009U<00O^I0QvtH;-hN_KmY;|fB*y_009U<00Izzz)uoD{{KnP=mr81fB*y_ z009U<00Izz00bcL3k8t>e<41)h5!U0009U<00Izz00bZa0SNpg0p$Om1dVPW009U< z00Izz00bZa0SG_<0>4lI`TrN|4Gp31_BU( z00bZa0SG_<0uX=z1R(GW1(5%LAwIf>00bZa0SG_<0uX=z1Rwwb2>c`g5coWq(V?&uW)5P$##AOHafKmY;|fB*y_@G}LF|9_@7 z`hoxiAOHafKmY;|fB*y_009X68Uf`0U*nEmApijgKmY;|fB*y_009U<00KW#U@rfY z5x)~iH!Ki<00bZa0SG_<0uX=z1Rwwb2rNJW3JL-p^8W%HSd;_-2tWV=5P$##AOHaf zKmY;|_>%wy1>NuY{}qAw>Q6hc2>}Q|00Izz00bZa0SG_<0uX?}Uq(QP!hp`{-z@n1 z|9_d`#EC)x0uX=z1Rwwb2tWV=5P$##{$l~;|Noc{2SNY>5P$##AOHafKmY;|fB*#k zG6Kl|f0?1ei9!GZ5P$##AOHafKmY;|fB*#kV*%v<|CkO3LI45~fB*y_009U<00Izz z00jOr0?7Y=nW4goLI45~fB*y_009U<00Izz00jPHfw}xoM*L17-LOCa0uX=z1Rwwb z2tWV=5P$##Ag}-h=Kue{Rsykg0TzgoAOHafKmY;|fB*y_009U<00IkJU=@YMzx}WO z979|@gYo(Qg?%BQItV}j0uX=z1Rwwb2tWV=5P-m(0P;Vs0|-C>0uX=z1Rwwb2tWV= z5P-nK7eM}B_+yL;ApijgKmY;|fB*y_009U<00PMWxCS5q0SG_<0uX=z1Rwwb2tWV= z3ts^Ff8mcYDue(8AOHafKmY;|fB*y_009Uf|Kl2f00bZa0SG_<0uX=z1Rwwb2rPVo zx%^K?{7xXZ%l}UZ#3u`|K$HXl2tWV= z5P$##AOHafKmY;|Sf~OL6i#$b|7LLvaq$dxiZF8YamDBV7wUz8x*z}n2tWV=5P$## zAOHafKmY>s0?7aK7TAIS1Rwwb2tWV=5P$##AOHafEK~vH|AjiTs0#uRfB*y_009U< z00Izz00bZ~FM#|%Z-FfcKmY;|fB*y_009U<00Izzz(N&3{$Hpgi@G2H0SG_<0uX=z z1Rwwb2tWV=^8(2K^A^~G00bZa0SG_<0uX=z1Rwwb2rN_qlB-Ba6Bq009U<00Izz z00bZa0SG_<0`mgM|MM2uf&c^{009U<00Izz00bZa0SGKq0p$OMI0uX=z1Rwwb z2tWV=5P$##ATTe0{6BAjEeJpW0uX=z1Rwwb2tWV=5P-l!6+r%9s3VKIAOHafKmY;| zfB*y_009U<00Q#@$p7;e*n$8AAOHafKmY;|fB*y_009UrQ~~7wg*vjR3jz>;00bZa z0SG_<0uX=z1RyXkfc!sifh`C?00Izz00bZa0SG_<0uX?}LKQ&%U#KICx*z}n2tWV= z5P$##AOHafKmY>s0?7aK7TAIS1Rwwb2tWV=5P$##AOHafEK~vH|AjiTs0#uRfB*y_ z009U<00Izz00bZ~FM#|%Z-FfcKmY;|fB*y_009U<00Izzz(N&3{$Hpgi@G2H0SG_< z0uX=z1Rwwb2tWV=^8(2K^A^~G00bZa0SG_<0uX=z1Rwwb2rN_qlB-Ba6Bq009U< z00Izz00bZa0SG_<0`mgM|MM2uf&c^{009U<00Izz00bZa0SGKq0p$OMI0uX=z z1Rwwb2tWV=5P$##ATTe0{6BAjEeJpW0uX=z1Rwwb2tWV=5P-l!6+r%9s3VKIAOHaf zKmY;|fB*y_009U<00Q#@$p7;e*n$8AAOHafKmY;|fB*y_009UrQ~~7wg*vjR3jz>; z00bZa0SG_<0uX=z1RyXkfc!sifh`C?00Izz00bZa0SG_<0uX?}LKQ&%U#KICx*z}n z2tWV=5P$##AOHafKmY>s0?7aK7TAIS1Rwwb2tWV=5P$##AOHafEK~vH|AjiTs0#uR zfB*y_009U<00Izz00bZ~FM#|%Z-FfcKmY;|fB*y_009U<00Izzz(N&3{$Hpgi@G2H z0SG_<0uX=z1Rwwb2tWV=^8(2K^A^~G00bZa0SG_<0uX=z1Rwwb2rN_qlB-Ba6Bq z009U<00Izz00bZa0SG_<0`mgM|MM2uf&c^{009U<00Izz00bZa0SGKqfw}xoM*L17 z-LOCa0uX=z1Rwwb2tWV=5P$##Ag}-hC@ARYkpCCpz@j7wKmY;|fB*y_009U<00Izz zz@G#tC>Vdw|E~zdSAW`pO$a~$0uX=z1Rwwb2tWV=5P$##{xSkW6b5vT|7OA8|NqMj zCr%Us5P$##AOHafKmY;|fB*y_@E;2>k&YB*XeSVRi4TeAiAh8^qB?OIk%IX>^8@Cy z%!im`m_3*cndO1PFfdDOsk;sR%-}bmpe&MrP`o z=DNm4OQtEP2$Hm9e|pHw+*H>{n|%2)DuO;Ot%+-3kY`Y^t8cKSqqnzfu%V-`qq}RM zp=+?Ci({}OyC4-|H7(sgwpuJeMOgXggU!SJxcR9F3O_s+=;#~d=p5|n=PSTRMbP|V zpP!ejuMVkWzrcu%p`_ks|kD7USx_Y~~ zx>$I+hI#tB>$^q>aQ{QUe>5?R@O3r|b_{kE;-Vtx{<9}B4heSmBRz|eU$83==Rcg> zA5Sy4lYa>nLH(aSgF317P|x59P7W%9?4KK$ggAM7I+IRs2|E=*`iDJ%e*Uh3!4V>C zRD{hx?C^FBcJmALQFn0(bPWn}4f=Zle5_Oi)&Hd_&*FbLcN14%7gAlizWyP>T#Kj( z@_#uL# zU&8bcMe4ec#+&4_gz+DCY%no0GjjBCPGk-*@uR z{o_%j>sQmyH#m@V4f4`b5tRR2%?8IH4}Zr9lR!T=Pj6RVnt!p|F=DP?A?km)NPa(B z*WK4I(AC9^BgY>7t4@0Kz?Cclf8$6f)$tXt&q#G6pKmY;@Tp){q^he?5 zlO|F!QnDIyYcyAD$SJ6=mebN$yIOs%yzFWjO)ZTL8uGGhW#!g5`FoS*AyQK9WaJc- zRMa%Ibo2~N%tV$&^cMCk%Z<$bKKF5UHFOO3^a=6N3M9QqkzNbEEaoOpe!liBd`4zw zq^XRnA?bzF`}aqGdzo|dB(<^qefQk!mp#j3d(!MIB-qcw(K#f<$G|nz)!Uwh|DQaA z4(T<@HE4sUJLz@fpZB5Z;p*%a6yigAZMJ9O`j<~(&%*md9sjccfq!}I@6{}#8qa6k zEG;1=A!T8<$-pz%b(4%ei&cPmkffiFxt!B>neec+25T(0`^n2%gj$BKk@DLXZnnX| zW23p%n(eyQ7MfPN`eN$xa$05~avPj%^uvt|^v&G;0<6Nc&Ak-mT+PH3-8XMmG>tGe zc2`!mXAuuG_K^(UAhXRY+)qC!!bCw^%gVyY)zxWRfWLu_lfI3O+{OTF1FtY|Loekw zEXx+Nvaxe4;pF1xS<1`DFCe%~Xu0r;m8(QV#l%-jNJ>e|$jZsDQBYjFPHDZeimIBr zhUSKiTG~3gdin;2M#d(aOwG(UlZN5%qaFNDU%u^G*ydZzPnPUii1QDd&&_S^Sr*Md z^83`uo`qxnu|H44NY~k)cKpk!o;?fud^7X;+4T=6H9sA*XW^W0{^NY-e<}3$83^fe znm^d&$5XOrVg1v-KPI#GEZp;t{xL!RV-8HZ>gRXFw!^U|2&;P2Zn!2 z|Jz*Bo`vUs9{cy%{0{^3PZQY>$N%k8`*V!?p}4>8{XIhcuw%oYX2w5w|1ksqVTAua zzx<)>e>Mf)I$s$n5Zbe_%-{d57Go9xmF9sXyG^B*7piR4jbJV?8P?_n_Gc}IwY;^9=El5q`U)8mMDn~lZn?rtzx%%N)$MQXBY+l?F{;xjdqZ?cn#J>kKYZsR=`3IPh| zmOr+a;EtUlHPMmU-5?SYxsCq5H0OkN(CMD>{mM=(%hE*pY;tu!+~p%Rd2M=J*@I$T z(#Z9g%+cPF%iWyA?hjVweamyO-a>D*yqeVH#pz?qZeF@$et#8T3}JXI_m-Q2YR%+k z6^a4H(`xUp|-KLK$NlkhWG_`G~ zIg=#8UojF%PJLo&j%DfQCdD^(XUm!o-;otrA%5BJ<;Cx|VJCKI zw#vzbJ+x|jTHQiw!dM|)YJWg(eO`~v{tJ3+nJFH-ElzLSlDjrvcBxjE!&*|47Ed|9 zig#Icd+Ou)2Ey8f+1MubPUf>m>=yqdm!?P+Pin$FY|q@dU%%)6lVV+k?5N{!N^Jdf z4rVVi+h5=jtrJW~YV!Dl;<>@gFM1gDRZ|_aE?vD_HlusUw0Tjjr-0$kQaMgi6F#u?;zWGkpvu_7&ZgL;hNr?k7D~SQu8r+gzk(x9g zc4i6pC9^*~DQ@fCW_a#g-+|N5h5C{+`YXbc&b>DwH7S1fie~F-XG3m3#!lfG`a+?% z!NHn~vNQT!A1^Q0KD?FG;}dFV@6nqE zTCZXDo(Uk|b2sQ~X3i4>Qj-t5r#%9E?%&d?I(&Zp;kPV-(@Z0Ia{8aEOuH9mvFHKGqU9Mas;=Y8Qafo`y9dAx0Nxe_r`flgrvZP2&1f@Rc=){jXxreOp8E?rI z+;*Z`rrV8NE?-O}e~qlAC#eb7%eRlJI_p(WJn$6Zv*mRClA!T<9lOuj_D+XYC07lP zkec|qv#4-;pNXdYe6h%oWpR=J%$WKuZ|~vjH$Hf6peLN1Yf>mCte{&bo7U4%uFXRm z`1D*^i`RLFYt{9&hH53+Mx-Wb=d@>$^Pf)M$o} z?<6&$-xoK_lTNjZy;5EKzFkC=MQYI@8;`ViPuPNMa@+%}NKKk6LbHzXrd{&(jUJbb zq3@EQy0-jq)4)WoQZ#+Zr1vfs3IZ*GhoC`lCioF{6HX8s2;F29WGl!-$i&E`$rQ=f zk!6zICTAdDO}>HLf_xi!9C>>*EiD!8Dq4M7U)ogK3$(4Y-{^Sh)aab)66nh4?$Zs^FaB*ZPfqsxq~7w! z$vr*W-||0`DS>pu0s#m>00Izz00bZa0SG_<0{_1R;&z9UQ6?n%XGhaSvE7-P8F*;d z^d+V*epxA-NNmpQle?J(mo|~nGzq`jx^vY{#$_8>HmL1uK1Lf$wUI?USS)t$)#Fws z-m6OOU(V*VUv*Kq+cCW(Z@|ZQV*JQN&XKU(iJbQKcIiyle2;;eej_2WL|P%bzJq*R z2R%LFn(B1q%op)J7hnk`C822vw#x)%)k$pY|@OJxUp`qPM-4d6@h7xMK`7W;< zijVA8xh%;S8+V!9@@?7S&g!o*R~DaXc=?GnTr!64dXn+ms*%XA3{iH0dJ;{mBbJ}( zN&jwIlOgD;XKi{ZL+G7egy}VNfoJIzsWy5Fx;aI!o7Fsv*fI*5<@zK%FI~{FkvN^n zmzvA+LU*btu34;Eq%Y}n{^yIq>h8@)LO60ZySKE5Fz1+kXs!<7%dz~>@~)4&?`Xg8 zMrjVSxYyAOHafKmY;|fB*y_ z009U<;IAyeMaE3};Q)b-Kt}VoGE`#8)f*+dqiy}Q|00Izz00bZa0SG_<0uX?}zZRgR zpd+K4TT#rd$SEl3=;rc28Sy(Q|Nra5um=JVfB*y_009U<00Izz00bZafxoT*1qA{5 z|F1iIICBU<00Izz00bZa0SG_<0uX?}|B(RR|NlQ?LL&%300Izz00bZa0SG_<0uX?} zUs_=P{r@Qfaq2H!3QisZ5P$##AOHafKmY;|fB*y_0D*rlKuJMHM~=V$|IcyoCi7K=qb3!!kvR% zgM!Ie9TWoshz0&lMy0 z-Hk12Md8cCuBZIGoxGjBy}bPfyaybYIxThF;&opsC!L!Vf|n|Z+%0OQgUrl3QwMtif7c_%P+rP7>Vc(==SVxs5efs=h-1Q zlk27vbfUWXeDlYWt;e2=4rX0==y|n;x|qE<+3=*!v7>9md>mgCTzKc{)pF^?pkCyf zL5CM7J45dbMVzZEwgG7?{cZl zM^_wKcA`|otz5=%jiI!m{N`grQXQU0-D>X1b;#Y?VDMD#(S^1P%@^t}bbQX}Tiddz zg|4Ocgt4Lhu^72&UycWEl`pe>d>wqSsr$NFY;dL z!OQz8N2`v79p;xEmK&B)^^Ljty=YQo%y+Eiz5D&h`;U$ikG_$*?iKSW>Uq@b@9C4o z{dI3IToyl)wLQyAeD!Jt>vcBUZK$mhZ0oI7SS`y^S{-Kl-s-(Ahqc|MD^3y5MvuM= zxyO^mnx(LMaZbfM`e{wex1ujx{W1My{rb-fpC3AX#`gO87$0T7>H2S{ zzr=n~>KE>}Uz77-+t`ahAItWm?JwGVD@jjtk?8`{#XH`cFEw2fxm0#(+e_X{Ne@t zI?!@}$opaGlBKHaB-Wm5>HMtJlEd}Tz&)!nzw$_Bb>->GtCh85p&z6^=(yil`*7`R z>-X6`*>AHyX1_0bX#KGIA@9S6Y^9vq>=dOuf0e$dyVWlzFO2OR+dCHW;pM}WYEL~(+z~&tXvj3II`VvEP2_>d)X2oh>M6kqhM^@LlGVf- zVl}Zg<#I|*N_9%DedzVKG0&o3Os7uiPfQHiD>piSR$R}OOaACtE=MkpGOx0-a`bwk z^>=co)-x#YUq2Da5y|&Sd_??<_}Kbh>mlnd>ptsu5shPK-7mT)yPtMH>3+yP=|HA1FLlXjEuZxUKM1;f2C$%f4eDg|1a!n~D~T>W?}W zmGUk0Tj*5o_~6*pPNe~*p~#MjUBj$zs;)6y8;*K2B{4NK&htI=`pdkbN8_zytrH5P z3L^>=Z$1up4tEdt4}TaQ=Ksh)NSs_bdGdSM*ODps$>EVlW0IdG$0f&hyqkToo@aedUkBKYqn+f(5&CAYTv4@I!jst zYBDPHTT6JFJNj}z4}4DOvn*CHzAiCc|M^(6QgN8kaQbV7Xoqj5pWl8C=*#R&ZywgU zE*ov}O}USuWl8ZF9s48KRHALavGzr_5Kr*ysbSIA%0j>TgGu>ZyON~2SY-;hT4dbZ?>AXx zoLlDXTAz@V%%vp5cQ1T}vvK`?E=kubM@MYx`;t;*R33g6?e0&?laX-yo;u=wLc%Ps zukrr<`*%;}wabmw-#0pPRfa)U+l5-T!7V|y#Kq`A(GgkM5AG6;Te2n{-oLmXa^$5< z-u+j35dvd6?|F`_lzHxLw_a;qZJm6c+x=qW zwj&`y&sSULRBm@?x+{8@-fgqXW;eqJ1=g~a@7-57+GKrkc>dy4i1o|!d=IwEc(ni4KbM@w?XvwHrz)-UT%(VL2=<#ke|Kuf`AwlVmqcEs4?Y-o{otl>*EhRkS*`2% zgZ3(yu%@A-9g?*>#y<@#Y8R^1ue?`ztKIB;iCfFSk@m65B`%WpVx%Isj(;0qYEN-f z&Ah5Kr8Yj+-f%v}J@39s=Cu0wp7sytuiu-IpQ>6a-H`3vH1hsy;Ar4zP{qUQ6(yUE z`m=3=YTgIm>pNt2Sa!Ml2BVONylcbW8{DfFbKm|jX=$2fklXwFkF2vQ*SYIIe3aoRt0$`0&iy9X$nGvb`FwuBp=XZF%KZ{UGSfJ)hK z`(O13z3jR4z~|6Q7&O<3g~ zT=~9hy@>Ill1H*VTw~3n1`+Sq>*vZp%5r}c)U$SMOT^H6<6H&Fm1i97DzB`G7#ywn z@Z}NDQH6DWQLl@xd|ZE2L3a4{6`E1LyDzh=WQW_YT#b-?cQq|cbU5xxP{fi^iFebP zl0E&SEu({z2cnE)v|>VIVqzYBx0}2=Hq|rnVEE7*)!FgyWRvleLSy=4_ePy3!`>M` z%FDSfHCy*xe$sX{?7eFGY}Cw??+T-D9?VM3wtNqoeABbz*sSJ^Z8mN1IZvKg$}%Wfccc(cEKT^!X(`sZxk z;%ye6JnA0Cme7xL42Xqa>olo*5^F}~PJd1OYk6mX>=@M>wkX?AL3R9JineEM&)wes zWwc+uG|cBJ#}}LDJf?o(S2umxM27y6th(J=!%tUGGh8-wtZLJ`#s_LgRx!91F3s3KYBJN=v`a?k z$|`P`=tC;I?%p*Cf8~FW?IfR-v@|DQ!DpXFdGoMbhro-PicDosHNOaEQkM$W&^NDQ zNNBiIPt4{#bs(+&!1fL6;<;~av@Cpm@U)wrq{pE`z8dPS`;Q(!QOdRLK!xUN6aA<3 z7qwao*OacVA?w)dtD#V6`}lP4ny1VcwRugnpVEbBZ8foc$`qolY-0R$fA8L0&b_Hc zi&&*u8O|-{Imb#9uy{!TD{*8o|44dpn!1cGN7IV6%MbOWMwzO8O#N0w=BZP$EO{UA z!x?esC!B2dd(zVOmHVG+>$1q%uN8OhHM=nDe%{wFDsx1a)iD7dOu^XrjUEk4b$Hba=fBNIqM&)ncpEfU~Rc(-jIw6Oeu4HY#q%5_VR=bYx_|bjl?j;t4Gf(-5bQzgc&fH>I#$V`m zQ*_6ss?=E>XFJu;R=y$KgmUAad)^!g&!x?0xRaRsY}*7E)0zA2WN^|X$#ydeYOW|0 zIUfAxrd`)Q5ywVv^R6NdspnSyTSE3Hdx(~6ox8n6{o>ZiRwwQ5w@yX@cb=TzXBCjN z#IZju>-2uVEBP0S6iu3!S*<_e^U~tZ9hp=^&8u9I9q}g$xoCUY9&89JwC?4T<9VPS z^kjXoZ8R%{-?=PrH&iJm zFVJ8R4L*L8R@9*2CcWr-wbw-j@%vcI&Svf-nv96|=>CiE@v2>3T@lex^M6ImeOnSaW@5z7w_7f=~}ny?BsC|sb-<|mkLV# z937t>KK}Onjf30Pn}!|FQDHBBwQ?6@aYQ^@mAn9L){Ucq$#344xb}-=GLb#5laU}C zl(|LdKeT8=efqf~y6=9}4dm*6`N~&fayLdkQIRTlyH9c2R{QAP6Bqb-cWN;uTHYad zz8jmP`RX!7>c`~Xh|lD*`Lo{^1y?BV)eon;nmj%jc=?HXFfFf^_Tx>#jJ(>Cg_e&` z_R4==x1AzK&95N6mu*$ZQMr|^sT@VZo*Qk_`*nGmvtFtBE>}oB*0j3EB9O;Z`$aml zc(rUV?`Mha#H%SPYWxK$j~jYfKZ~r}ucDb(7}U$~S@;`$wBfomiK3N#8^0ZlDN^Oq zNl0^aQRGtBN`10it^VL*Q@z-tzP%peED}r`^k}H-$yzL)zf*1WTXnxz-sHKmobURt z+@*${Z|?6LAk-QpOVgOq_t2a^;Vi$KTj<7er#R94jWqh&o6pr)uDY(>*C|5J99;0~b!D@n2^0BQvs$yXIxLi% zZp1F{eJm8q7eQHgaK+){Bax5Q>k^nXj`c2G%2O)3pUEx1W#`+OD32;laSpoVvVBzV z23$)Ye(ERX|HN(Ft&hWNDVh_sZpCy{HI$2TI$9=`d&*z&(Ky*}!@;>Eew|{HoQmSd${c_!a~%1B)vA z>`vC>v6mG69H{8uR_3euQEJ-mKCZTllbLR?(>B`fY^z$=m%ZcNG8C={^73bDH7?a0 z>~9uu?@09B>oj|wJ$O+~AdmTyy<@{Sk{Z! z3ttI4X9=|GF&jTC5Y92O`?68f#|fNa+)lape7+E2Xz;duNr1opI2WTlW5C8`BZct< z@_YjSJX4g;%l|WZV#N6fo}OT<)JFthPNU*sFQnCRQsZf#;M38jSA> zy9p`>wx_)$dlAPd5Z_5wv66|ked|U4J$Hxps%Tq(8*;|1qeWD+Ip=syn675| zOQx0FD@;F1TsV4B|C8wPgDlSc!AzyWN~@mnvx)9j?J&9HQIKf5U8m`_sUw%mpzFAL zhu}wtb{>O-osnLeA6MReE!rixdHgv8k4cy#>(Y>tnrd?etDLKmWxk8bj6>o&H%#Ad zkK9|8mU39bK-!V((Q5&dZ33(0C})DzW&++6eI6-DEZecq^1AfS zIk4K%4IkfnGxF0cg<`_KUCi=SDl%>LBOfqi zAFv4Y5^XjjA1I;^<*8oH?zWEVtl82PEv8D%66`A5865n|zLqL`=Iu)>Aq%xBcA;rj zPjtUS?W8#(x;LXCAWU!#$C>B5ukzgXGBF5B2-|&oh0_?5li4{gy3|CM*r}31=$;luKtv9RI?4ax&-AtQ9)DVa!zxlChXO8dA#Xih~ zogAI7$44@h_HPY$5UdT$G=3ovy18I-$8^TdL$`;VQf%xNPbE%2x%if#Sd;ZiUv!&w zqPT$m-rU_?$5iZM;_e7~@b`P(HfCJXP#B$(D0Dm3DOFKVoI`s*SwIr$$3y$n1H{d@ z3m@6AY`LCO(LRF}W{u{ihC>2jX`v~Jf&sCLo5We~-8pL|Xt6K-_FhhQXD9X}T0_fP zniw@`g=L#MTsW%t+!qXLTHhROmAN!CY;Q7chU~WfE%Kq>!KF(xCRXuo+Qi;(Pu3L^ zwuZBN@3VyW>nr%>FYQYyJ?jvn=0GoWH$3XT24{?@(qeJC%?UHgX*air-#UI~(aC+I z41*sZD@kWG@z`;dm2%#Drt{qGgyz*G7Vo#T#Sb!WT}v}x;nl{tX0d}1d$;>pU6*E> z5XZNsJNwL&lgak)4*hg4*7z_h!xUjwU7=K$qs<+w!TcjH{>fXFxAHRg+ zl$EfAmQ$HasN4;$lQOm&%hMK7#!(SCOzKvoH#HT?Q7^q!AQ!;6zqwg4kbbJDz>}_1 z&wN=)TAAg}M89&m)l{4(6WZxd9W_@nS+>>iv1fV+Q{dJ(!qD>m?KD$GS&z;wRl2k$ zFYLTAy~Rn#Z-GbpTyNIW28+xXyl?0mlTqTM8qZ%ko5DM*J|x8Ynq!t@wvP5`r6XB{ zlvc~R14o2q^&%eE69u;mEM}m(`?YB%)xooLH&Nh4W`9ivRr_O~3~@?fj$NWeHl;v@ zlHK*A(hf1Y*Zmt<3co6b_$!}X^u;cvrE$9G$;-|eVedK%?^~e`RkCmSHELhfvTld7qnHC!;PHmc3_8-aDngJImvZ(JO+&ZObmL%hK1l?c>heG4wZk#i+34qvkVJ zovd_r7h&s-e9MUG&s>|MFffL}}2%4Hu-r$$`@&spL}^);h={aM=?<3|NN58e>&&JVXbtzUm9T<+tp zUEU_d@|Wr%`=4!%&oQ51^Hd9&xRpqqsQHZRIH%c3_EO4xQ3;oiBL^ddR$bb){xEfo z&x(Xa<%>#PlB1}5TUlhJWB3Ges;w^cZaj`E{3AbN6Al>>AkQyzowm5AWojbN|xQ5*ZR=?)3R1U zJLN53t&2Oovfu2iWHKvPS>Up-Y>IT>IU@7|xW6(gU8C09elxQs+At#S8|~oDyc@HX z{`YI5wfNq|Fh2O``E{0YY3qUVZpjf#o99#1vv;WLwtlsZBHOX&dV^{w|A_I|Wiwo} zv65Z)zFL0gnc>a3xua9HiEmWAU4K0FLt{+f&h^H20+MNm8r1{$E?uE5uu{88I_v41 zul#hnEce)KVH@P!PM`f9z;O^6mXKSf)#O_k$OO2((-6eO9byZ7rq*bGE%~y&ozWPpM7`41ytMI&n)O*JzOT&5d+rN~3FW6btb8BkJa8#` zJ$rdmIok}*5KHb!qSQkUyW=y|Ux{WMQAayF6M&OfKvxf-5?&GqZk_Bse40nzTtZWkT z*!YOmoqR$hIkWy*jd9r)`ocA)!b>maJE@AUF|9Tp2^D2tVI*otwK6*~W0?ovSYO%! zk>ylvH1brLuPKX-c50M#K2kh&LFWFj?*_AziFpx*-?!MZN}e^|)@H9be#mBE|3G@_ zx=^ua0d)R{%%6=cju1I=#^LNeLhY%8DsjBaY0O$aTUPX)xpZm4^P`lTwvtZH1h4gboVL)V^sRqRvhBVHJm?u+cB>C^68*;nwn^7H;+-5T*} z(dh&u-C3c$^q?MwQQ^sbQHBrG{Ti!!$S0Zaew=D3QZ2q-biH^-u+GZf4ZP2F*X0yWVv9ZC{VI`aNsa zFNUpt7UN+;b#RBv<)rRH>Csm8)blr`^XW6SMbd7KtFSz#u0Q81s9kQlnu}ljNNIRY zvkBW4*6V&*S0WAa_pV){pR_+e^1DdJ(1(F^aRH}Be!@iDVixJnwSf{x5@k3apJgj? zq@Ie6NBpH{!YJkc#TWP=hD8tjYhZ3t(L|;u1Df(?HiQB0C>!X*D zeP{SytX`Jo@jhI*=f;cX+D83hC!fCPJ1R}>yByc<&{MrPaO13frSUeahk4DC7u&`L z(vxVp7JoIXDY{@y{XHQn{b=L8Ej{mSQs1067EKrx>x|jVb(bnokgHaz&5tl;Slk!Z zQ_D7>uemGlF56)n>e&X_uh(BN#ynEVz7ae@k+0tRg+Kmru>P*cJ56KasXsCGe0Y^f zcY3XmT*tNx`c)h=XZAR2d8`<^5&zyroSkyqaJSjT>=Q2wUY2*sbrjYtv3@o5%%^qm zvGR)P<1dA$iz3N$nw5)Ro{qGcNb*e?vhJ|!Q0WM&nXaLz`4A#$LT0935PKlIFg7SdcGCC!X2cUa))EbqK-`+(j5UE z_i8MI&wR9Nxmppa8KyCDVyI{+V`z1UXU9wPPo{O+RI2RFWe3&c#YKBe1ykrJ%~u`Q zP!@e!qCp`lS9Ei;xyOe1dh^Ri>03>@Y((XR47EiK&uCh&x^Y5~>XvZo$GcV!m{f&3 z!x|*ba#ou@3?XJ_ofirAeLi(aTV>nZ*)A3jv6k_#8SJj+wU%dXj=c9cbHuZGrMU%% zQ(cV=w~Byb)&o1Ipik>FM?c>;>Za)OKYPk~x1k~r`@71un{}VPb7SwWR39(5qAKM- zPQI|BMeJ0A@ z^|y!IRs<=0V~jrWqC+a2DC%M+xU#z8h)Cqpj!g-z?&pcy9-4-y7pLAi>cp(yXx<-^ z5pj!7`_y)^_U+{7zwExhU$27ktZ-rd5u0rp3^D=XR%Qo{cik?LTfA#yQdw^e&%s)G zozj`zsRO=LlX3!i&jof>8k99gQ6GHdaZoMfy*Z;BQDDM@iF#FY+P=r`WZzC{t&%z^ zPu2f?kgCTnGbBjkQn5)+P4v!JCFMpByT_lt7z?_hu2SOri20hnaOD(PjeWYcKkq|x znMlGx8o47>Dcv=u9pa0Uz7>-uS6}XhJfawluuw3}rWT^O`8*?1Od+i4+a8nQkb(T2 zN!Q}z>(a_~{7tMPGHShw3bH#ybd$^NI)%q#kCv~GTDd=hav9n58qqB!yUD-tI*l22 z22I2o$Lw3OJ9+Jv?o6TBeya_~N%?=@4(h>X(w}A%b<+E3?+0#pd|)Hx>h>xN|ME4< z9$!DcV-xG5USglU*3_DQVt*YHPXKQAdL|>`J z^OX6svila_^eO5)d+_@zjhq0nNS*23-Lq?mxgq&H)jitMXX}n^ zmhM@k#+PWzu5CxObvl3TSo)XNZ(}y+ee3p)yNSL^%RV)n)nqfvy|RC7Ag;n$#Q*%Y z#LN$?#RX+4R}{~VEZ1(fy(#Y1Akgj{Q2D_+Zr!$Y`cXxJ6t9Cmj@?=AQn4c3H?&@g zo$xrTW?rsZpjx3?e~-DsJIGbmyYBwu&W*K^Ye4_xn5Ll6_=KilFNvZk&}^IvnAPxt3qB*T29!kjIjC=gV^l~^Iom^A#uw1 zz(L)3yO&H4m|D4PIBmFXxNLYT6fQ_#kbmVs(2UO>&nG`@tSZWQTtkqZ+SHMSo!ZQi zmOUZu#K+QY4&K{J&6T5_93swM(Qs~48Po}QbyPbmL4eaGtixq&c2+8M%^F4h$d%dj zygCK8UGmA4{t48v4;~A=)>(JfDZ%Muws%p2AP>3ewXK^^3Dn-mx}197DfzaEH|J@e z<-CV?O_bamY~pDWuTJH(k-6e1HzhFsD(;oew{zdF<_Np&auR>G{y|3YdA@t*LON$t z%Q>qr1{)f+GQK?g$P0f9=SLlQEpw| z+O+D+WxL85=056lR-gdA0 zbVVyZ6FoX_#rlgXCmsgx95#*hs;_ftj}j2Ko^nmIr>>T4cuBcrJ@q9vdi|F%+Kqz> z()(ZEvU=Lq*k%=NyT>t~?9itC^)c=%pDa&^WaCq&5V@c^Sx;JyYwKsxyEiBHM68Mp z%sLuko~`qatSN(0NA{@LhyBD4qbGa@2tFNcsq~q8whEz(j`>Mlxu2`aLs4U_<8OJl zt)=bcctyrYXgA^MT}74yHk-QEif-5(R=k_STv6vZk#!gAn&L#BJ=}XdUt9?&8ev{@ zJkwcR=70$Iz=Q3%JrTdMz8hAA8>FN{fWt^*}y-!J5aIfSNsxOsxJ0)YZ z#z@*o-oi{!D_&I8;KWTfQMFPs_o3rAbIcEG#n)OLm(HP=EmbR3E0ap64>NP*U?0lA z=`DIc|K=9a`-L~Jnjbcfr!v2sLeEfkc5hj5Nx;z&iSFQTMj>g_ZiDWQdh7bgq{`%= zq@d)nqzUd1JWDu-WhLY!WM2Di@s7H2`S#_fm+yZ)9d4Ff+{}@JeT}IjEBhKVM+SDj;+vtO1IKSVo44u4Z#553qko*)W*F~Y zcJ>hclunDFp1-Mi`St?}>!P+46_m@|{kG=L0qT*wf*kU$1L~6^In7^LZ{F=Ty0uM0 zH1u-AClTlPI-R{8QSZl6z77bkX=6(sol(Ew5_NXH0E7QFm&%}#yg29fu`Qc~^KJ5l zCbtxQ^rzGJ5{QfnR@`Bf9@lMLyhLd!>${S@Ub#+OEU^!@r@1#rQpIK76*?1ADXYTr z&iM8=-;tBUIUcb!sjPyqwc{ERs0MC*EJgD0R22)j*iXx0PYoG* z-#B-J)ok&(?@Nn!?~t}5_ud(8;x%I>DfuST-0IE|H_>w|m))D?nk^N+CnspQ;dIM# z#n_^qUc{44qf11;8x5E{gk=sk(MK{%4v?a<*nqx)Zt+=5sV_VIPMio}x17$Sk7Se_ zkp6hH;(e_PbLOsmF&FVfCG!JR64!*R!|xs}-YK5Er(`J|Tdses?@}&G7n3;3_jy_0 zuc_QR@a~9fORUlz*~letiVs@*e3lZq*qcYkOI+6A#YVkEUODw9*+CJprElr?5IPhp zJ~3X?f9~VbNk_7v7=kJQEjd7a{U;a+Z2|_{g%91@WjRz zCxV1Kf3Q$wft~N(6cb0Di4S3{k21rDv|fKbk-ycmrAu{}bA1rskq<*OxvaykZ{FW3 zV+;2s^V>eA&hD|@Q~E?!^XE%4@i&s+OpprBoZ^LoHyft8*%>nP2FR7&?hU4QtAZ7TPo7j+FMQk9m_`})2e z&7*N}W1ebZ_W!t_yU=82{mlFEPm1ihI^A8buH2^JsuUElNZoHuevmUNT>fs?ghcLb z*0YOvDCNXMS8bN8O!JT`JmGMpu3nC7H_O;-^Mjjro@y2ll*!4v>|(Ks>0Qf(%JnM_ zduKBWx5S?o7hLi{*1Rpfp5hhN@>xBfVg^64H)^)kgxTwRoS$2azSDhS=OLCx`DQt9 zwqE|}Q=J>Hu*o_4yc74}@DnqZO%|xwO5h&cH8^|AuxWaug@Ku3zQLw%`>$jVb=R8T z4Hs1_*>;yRb|$_|;3S##X?2O%{}0AMIlql}q)dM3S((zQuwUs}nX*_{D-4Y|ej<-u zkxzx^HP3{Y>LZV-A)1YKRCs|U`65gDfnrDge?Q()8`#RJUW0nCH(g7Gm+Cy>VT_1p zO!-1PvY2;so==l6Z>4@B!qmppR?{Uj-DE0cU9xo3&8Az7MW)+K?M&^_DN{#NCvk+S zi>a%ro2k30hpDIWlBu_;kEySzpQ*p;cGCbV9HGKd-W@weh2vCsnRUKip~9=Ak_xX; z;dLs!L4^}kfcAQl2Pmhg@D>$LQ{inYoT0)yRCt#O9%ve58f+S38fqG58g3e48fhvr zjWQLRMwojWWPtqJ$jW9sR@ z9m^_8Cd!iwiXEPsXZKX6p-(Y6BzJ1O>fTauMCsZndMOT%35JewzkV8CsQ+%nN7QnC!vgSrDL*4?&TWW69awyne!a0kIP-BtO^QsjJW}A?C zcscmQG^22qfCI2C?Y!Xk4K3yW?B){mbEuq(z;;gTj! zG6#{zJPyraanfa$4VAFPyBb7-{NbZ$JQ(O4 zKh=7LU4qa~mT5S{q|Xr}mf(ZVz`gDPNkHrR+G z)tHV)W^+Ng>5$3kad-0oYpP)Of2D$)0mvB|B(#>lQ$bQdQVsGS*b*1RVx?j(nJ@E- znQ5L0#c&&2rctgK4;8&>yrCkJaixm6jaSS+XqQDm9t(zJRxug!dSSOi#o%V_TXQ&- zLd7iR6|({~VHI;3G>cb^n`s0M8G%M#*ix?;?nzi7-3^(%JMKwXAu$3HlDPtgoCU~P zaaX{Rz*}M}6>|?D?@?Ebh+#`XTW!qx07*K)%AC*Ky5pSO6bNjIkh>LK=YpczVkM%Y7QxZ@J&HgSq}JeWv9B%MxS2)Lz%xvK*bVthB7cjV%vb zRvSiJ)>_u39k6V$Y_x2W0+uc4xaAT3d1GVCb~f)@Y)r+gc^L3A`Gtztfd3b-rD7AZ zii%CC*o@7j5}Q-;IyMj~wxD85Dz+l0sMwlZPsQu`Y{YUZwqZ(SSaw==nHyW4(6zEW zW!Y_c+VYHLkL6j*UPH0vIm`aE{g&q~FIZlbMq2;?0E-S<4p|QCzcx0q9AmTCMesm3 zu%K4Fk^D@>o2Ym*6>p*9tyBaG1dZCWnPFlFDt2Upt70cAcBWz%a*~Q&so0H*J^A#? zGAi~VO^3#x8y(4>TJEeI6gx5}SHwc$asBz)7D4umM9q4!g97YPk0GIGZ#KH1xlIhX z%0M++`RG4z!)sPJDlK+o@PUf|x@=_pGDeP9x-+a3igpYyALz44= z`5$0LYI%V0M~7GsNo>q_siBu}^b!^NYQ9U>oabqW1vr$C3Zy1>ITyQJCH5M=OUBRh z>O5riCQ0!jD<*U$7rIg<^jf}4)|wf16(Cos@+vlgErmkTET6%&T}4(U?|n0g4|8oF zCam`@i)_kwspWMwN3T}pwHe&O*oeYM*SaPu4>hyHl}8n!Ma+;`v~!St^!83=Jv4hd-8j zJ4>S*qiXoygE-@i1wD;K$vq&C8d#^Ysb!W&*ara(_mXw$O`d1eFJ4bFuM~;4W09zp zqFvUz;r~5w%p%cDITEd8k!T_NHV{rFjYQ{xIxKI8L6LTBwi{E5kA;9jJsA0m*Qd$T z-4f+#rCiX!7R(;EvkwCA(U(7Y%bu$c+rUN&63nEquC}hJok?NcWZjIqTDQVcSE_49 zb?NH0gql-bYf?aUemGIUJ-T%}cy#LyD)v`gymc4z`D>vi#ZNrNuGme*0h-|k>mCrc z7tO;ERIy?c2ZGKik2YAJN4?iuU!dZkI-?DiP2w5rZa&%|wgQ>*XoK~bdbGj%iuF}Z z$YFiM`aasX-8jMeruC#TY(8K;ZGGE%2Awi>wZ6x~TX7f_hqIWIb=gKxaU>Os*r3Ae zR2;S4`hoRB>qpj)&HJsNTF+WPvwm(pXZ?bT#Z(+k#Q+t@@GfWx6-%iYq#|@B$5C-S zX*!C%<)o0mP=s~82UqbWs*&cRq9RK|PHb$ZytYOYDfLig5* z;-OQTJc{Qo0+7}>p&=?ufyzi$I>S;yo&@Ac4RSba$*B6?I`O}(ZL;`Z$*1)+$ULpi zC#$~^$>U;%eVer^>)Uc%EQ3e#T?#5&&j9p{T4k{awq!hiZ=H@ALniB(DH1hZ9l4N_ zbaiSf<~`8$Jx#@of{iyFTRUjJw)RwjJ4BkE^j zMnnQr`YDTvYF8DtD~pKMuKQ_^1WU>Sq4FN#u4U}rUQx{rzrtWi(#JXGLwODtY_{R? z8k@)p5wf3X(Q#ha3^@XjBQ!`TwTe_^js)aL4HC*Z>3toBECOUv+%h?G5^TxZ!u&dS zk{B}Cog~)mnZ#m{SR8NmTq0C-!WBv5xf~71(VAR>9bKtTYVMdaWB`zX_#IOj8iOry zgr*cxd^uHBAthZyRnb^fKOx0|NuH33=d={`E!E_-3bqt#MaxhVN$rYjwvQQGd_u*$ z<81L6I{|eNiJ$nAo%o81_h`Za+c%)l1vH<91GAYbb6CPDF2ZU138DepMJmqyk5xEr zU$JO_K=t1v2LrbMse=LAU$(zBJYyH^dbDp7yTOnav+I<52EsNfF4$x@*o|pXyNQbT zQE?%gBPT8*O@}CRtCQK~0*xapX4BeIbYu(a-r?Mu0=vhNcAa3e>(oeb0c^Z1>{mf; z*c(xCu~I$uYiim3N^$T_jM42a&@Ou`_zey@$Q0y_dZ=I%V%`?`Q9Czui8-UT7a^ zA7meFA7USBA10RAN7zT&i|nK9#rDzmfPIX;#0~-0GAceu#pP67LB*9+T*WH>Au2vh z#nn_?Lq&*8*HLjj8xq<;#f?4)U@i-Mh}miqMTX zNkwpqZ&C4WDxRU@J5+p^itkbJeJXxH#Sf|Y5fwk?>j6fBlPgM;n>>MNMR-^!+GTPD zd%4(1KK!Gs^f`oGH<3-*amQbo(-*E$ejO!`M8%iskdbi29{wtJIOShE)V`@^7zD2a zr~d2Fu$olZFG`v{!tu9hxmmm$h*d;X2^GmhS#dSgs`V&UCfd{_;EX4rTK~flhr}-% z6%9rvhe~R4R#H*M-X|CdlntC3DjgOBp~_Mu4)x1<*pF~3E+L-UkeKo!7GMab3I}>? z^oRzlN-GpeO78zHcW?!JpIc3_t8%YWzjlQbhg@yyt6XYLd`#5U|1%KLy1+5y|Ni+dG59t=%#S+dB4A=?A8y#@&_`N8A^G7Q^+h3fVWd_YD4 zFXy{d1a|~zM~z@0j}@s2=JRCioz#L?@?El_6$b7M(9ZJEih^AQTe67iJ{~UE#F~hT z-MopANb*h%li8Ik+*Rq+aG?+JT_T0!mD>%V-82dVu_j!$R#DjA9gy8s3UfZb(#o|p z!3IP20A!DNu)&c)<7-mOY)_uqp6bl5<-26@90T_PXs?=hj)TEBg+Bj2S`RQ&2h3l{8~HG<@n3-H@ex0;OJ&5{z%2o)gxU$QSpDoN5#+Jgiq-RIVGn~ z(=&1!op!Wui!;q>a+;kMv51XsoulFxRQ!^PUxBM{x5epjI-M@3+v#z7srWS&zop^@ zDt<>r$ixpUtanE%B#NG_dL%E?4QdY7ds*yFY{#ikCG*FU~Qbd?{MU zhF*SVYX8F0x$@9U+;HTCL#X$9=L9NVsx$QBG>d1PqvRVgV6_+1)Dj~gF(ym=l}Y?9 zu|&;akF%Odyn~9r|HojDvp7E3b6Fnjan4i^_Biix&ek@ydCpaA5W?ZS*E!$0zGQF2l{8%b|$tQ}Hh<{!PVyC`J?u>~$p+i_jDwrWjK!k@Xbopy<1^ zv>tLk>|E_!<6P@p=UngH;N0lkdk?y7Ev-fVZem|T&T(;7GO&R+${q&6CwbZ%KJnXsoy7}z>>U7B(COS zP;;?bO%b-_+_R`I_i&lXs7I*{%^OS@E`>LkFkP2|u1nRrLIX-TV^Jwm=Q02;Qv)T~ zQmB2+dqtR{N!}|GS9CeZTdtO;gDnO7)=b^$s-k39bD&|@b*>f^`zZD+gon8>grB$_J6Rb>sEzD zafZyIuKt`wuQVWhD+_#FgV8S6Q20L_j@f{4x;!zW37Z&^$G+vmsoDWySFvlfhKpS# zt_cwKxJq3?vB)*fHQp7XxPan%Op*E&H=wxT7S}{qnXB9tc2&44DZYy0Mie)u_-cx; zq4-)B_jnko@mJap2$lrH@*{MrNom8Q)!`Cl1dM$h7%C4&V}bHYHM1tO#`__k5Kg+{ zw@+{`%OEIQkj$v7i;TJzZLdYQRukNL9e z8P^_k%C*uuK=*E_CvUGKTxcYWac(Df0U#g1>I7-Y1kxC6xAyHebZVrXSODDFvd zFN&es`cMo7(vRZ)r0KBoP`Gz6JT5kVD17S?3zkQRhoS?N2PKLf4nC(N9P1M-4F#C{ zXP3$ELXXL#zhl|M6spB^2D72yKx{~5AjaO^KD0bAl_xt4zQ~e>@kd(O^4#o4p4hN4 z)iL>Pqsbq8S`1bD4C@@b*aEEX0^C(yz3^(W=C+x z%7pF(k$V+GmSuuXv`ju2z_0)S0F3*%!fYMj;<&=C_%4~3TwHgi4Z|i~7o@hN_H)7e z)g{%M?@|!#IsniEYQbRn`1)itv$*bb8?N%K6sFrRt~&>XBa=OcSzF34^31-dF6B0S zmm2&f4u44vZ_9Tnl$Cax5W^;&CR8h!FG%P*q!xSw-=$W^d_h9jVRaqf$aks1`J3Qf zN7V3}_%4-3aTK6OHH`uo@aEJ6^Cbyg$JByv;k#4>9|!1hjbI3GZsoqf&0YlIUQF=- z#W}c_{_Cj$_X_t)jdyTA?0$@QYuszy>%=1W27J-I$-UXV1vha&;@*Z%xwpG_ zP&|m@!4wancqj`jhLPtf9!@cMfstel#YGg4Vh*F2;?XR*o$g)k$K6l3pL9QEo$7wt z{fv8$`&sv1_dfS??)~lq6bC3CLvabkr4$D#9!o{w_;D1Erx@aw2^3GHxQya*io+CF zun5H!Ukrdh;ifDtmt9#i$sV?n@d0oLwUh1D&+*lLL@&2aa&;yTs^;?#sjP}rRz!ot zOG8mM_0-E_t(J@HHI1Mrl?YKA7*4&nsu}wH(goclV9Mhwxo8I$+>+09_ZS1CAXETT0m$ z`O0%*QEj#%szHhG&edjm1J`#LD^{px^;|v}h0!I;`&Y@wrtW){asG652hQg%-1LHIUaGHTWkfUD4W=!q( z!m+{Nu$ooYN2-@$zk+eq+!0&?1`!Z!o|-v}m@T=)Dl6&B)ERUPK*uO{DI=kd6HXtD zBTE2TqCtXbBwB8=1_^y`PpJk87M19Q>NOdKKDQ^RL4qMBe5ZO`=2$?oH4JChAi;hU zuGh#&iXoxb?HQ+#l#ILswq#VZqz={K6c;~a5~0uS327wW30n$CLt|xy_ZlR7l|aG; z-s3VSg3O5;nb4xArGhL2WSIsDM38WC$atNU1F~FGo4_B_ld&m7Lf6I<)*xrVmV&n0 zv6o3C>DY@>m!3+HSgEQ@&T&bfv|z|dfSjb!6{wM>Qa%wtMl?uZ)S0OuqkxQRkU+$D zr-FdIuwqcPLuIMoHP+AH0sqc%ayEP2yovH5be27opx8yraC(GgwdYhW{8h z^%lm*P1mGpegu!G{Kz$4Baf5`-*)d@?>z6l-ud1I-ut`@ zy^Fl}qf;V;R*z7;jp9cs-cB*}p&p}nC&jxcejJ!7vfaDXyUhEbce!_kccpig_aX1Y z-ql{{Og%yIlN3Ki@otKrruZ3(_fY&S#d|5xO>^4>TNa3qxGm1gbKG!KTH56JoP=E0Fr35h1{Z~|e-1%O&`URP%d<~ew4JnSVAmwY!c?uc= zB(W_aPx+eqn$_}@ueI-b)X~=#jyh6&hT`LDo_d$!k4X;2$KXVcLQB4zeYa?M%Gb`< z1MS=9Ywzpe>u6{U90d&J>&7_hWr|;+_*LGi*vYAh;@3!Io3E#@m#??4kFT$@B=o4=jL%={hw-N4NJo&24}B7awk zFHwA%sr2h6e|LWme@}leihrZ{cZ&Z<@gJn=(6N&zcC3yCqyH!CV)eWn6z?S1{GH;p ze35AjaxW#bE{5z3$j))=;>gReCAY5S|F*7+DOwj3+ZDuijawHN`zvg6twosKL@E9& zZb*~4b%B}@R+!4kJK0s!DE>!lUC^QNQ?wkcOJa*zNCHbfRqL9~tZNRH#Q(CcX>sfN zN3pI&>Uo0x2mDL4mhhnec{b|V$iLjb!Wj0i@;~H%*u22M#=q9T&eYYv!N1YJ$-g=6 ztbeQj5&t&tBDPAU|1tkg|1SUI{wMrT`k(Ue_CM`^#=pn^tbeb6pZ_`ke*Xa~>8WI3 zcUL7NmC~qWVoQZdW-3{zWM%hGB^#CORB}+s$(D1MTvT#X$pdk*yr`w*rIL?Iek!F? zDT7LxRLY`KHkERyluM;ND&sML^U;syVU{+Ij*{fGRA{YU&q{m1;r z{V)4p@xSVS&HsAZr~VWEH~lC5r~GgEPy65YpYgxrf7kz>|9$@l{tx{h`9Jo5;{Vit z*8iFRbN@O27gV~6O0B8Xfl3{z)QL)+snmr^J*d=^N<*krN~Iu`#!_h0=qS96>?WWSxRDvrZ$Int}FO`l`=@`|CREMcfqB;+w zbZ=!aQW=a@1zRRar1Pv=pMW@<17T@eqG6QOBxCI20bI ztd?1%YM5+QEcu!~p>VAB8vbg_@MJbrTM>@3H+}}VfsaD5>#@)VQk)qd~vZOy{?M2!_}Ue%T!r})4}p!WLz*@ zQr$s*i>G`#uUwYUp{%T8N~iG^kW6?SWDU+Kj}B%B*iZ45MF&>Mxlva9f}g{|De_CD zRH*n8phH8^{(-6}D>M(Uk$5sSRpO~xeN>}uCcnBf9}Mqb#gQk9WtU>Ryy zBvhg{5Vm%j^4>0XWo0DTKTmT!pdcaLDBQs2 z(*vK{(28^uTem0Onr=(C3+;eemJ2t65%w+YR8h{yG=g2qf_z<7Dm?8pwkDhdTT!VI zl^UmU?$D3yTi97%l@<7pGg-Pjb@#}<%Pr8pqou}XON5ie1a zZS((VyASxP?(=To!q!dVIJPmypm~wR2D6vh2oNC59uivuB#>AFB(@DY>^;O1OH8qc zz|CkzXWF+((D=(L zeJktVz#Lw-Yk0^nUJ?Es_v8PzIChy~;bmgaXcOMJO!x5c@CORxxsLtBVB@YD{`z6k z*!ZjV3=gilqImtUUG;MbD-%{FO-!1^VsSFauBiW0#c}CzC24Zfl%%N%6_5X;B5cCc zxBow0oG^7#{Y!*4#Qpomsdi@4EUK+;SsnY}>-WDY&OSk6-=|_-E%DuNBhJE@IOiuV zNLrXwl%y^63ljSWi35Yg!9n8C#)@-!2W1o;Wt!(`Kr4Iw5 zan}t$^(1Ls{B;L}hk~Zh&o7dB^&vRTWmiA)t%YyNGYBsk#LII~cqnN4(!yA2u@rr? zvjv*l^?=);#7J}l03^`6Il!!&m08E2kp za3(1~>NTk03>-W2tTWF(oPq1le6O!x8@*#^CcP)=nb^5W?@jt>?A-eI#U}kJ`^3&0 zReyf?&e$Mv^xK%|7SCmpY+c`;`ktOVvsmFNSqoZ zPH*HbC;b2b0F6he(Eo_{@`Uj4J7&cHhS&Yy`0Gvz55ILrBu8SGOpe5xX-atbePl%P zbK!2!#qTyPJiNmhk(?ZElpMcNUU+C^Ml?!c$yv#*DeOYa3k?-EBS@V2RO}fl@tI#& zSaL@SOYW51Ir*jJF3DYk#92Y&oFH*tkXRTbE@-5}lEcX(xo^A@W`~F0O<~F5D3UxZ z{Cc6S#yKNM-pUzQT3%`BjFKR+^r`SW z3Qga}8M|Z7*ps|Bd0+DWDl?{s-==$1 zXNdjiax9*&tE>J;kP99H@Pe;L*K+#deYJmhUpHFbP|N5bap%MP+EssF&3#p7{iffy z93A`njg%e<_fmTK?>_!H_WPDu_3vn&TDd5Q^ojkizW&u?UqoH4rWiVZtCC+qCvj)2 zq8&fc zw7k2i*0GzJ5K!QWndwl#-OvlqD%kQ}oA!LE_;cu_{PB79^f%WQL_wgbu2R z&zeKw;djfhl+EFFH^*Q1NO<_IGc09yc*))IW%)B$HT)ronhfe z52T!p-{@p`Xk>;pijS6e!!hn|JRdE>!y}6ff4w4h`n~Yl*6&Opti?sqin2lHNw&e-zXAAE*3D%AcnES<0UWiRXgE^FiXpAn|gLSld{A|8p|j;r^3IJO#XF+r9MevsfnqP)OV%6JM}$5 z;>{rOc93{CNURGIA2d>7sZC?|m)bO53Ae(-@20TSl<>MK@z=c*9)4?urM3w#*(RQf zd*R{tp|I4h;ci{yce@`R-eHBMz8r4!a{NZGhlfTgtTEqzd?5ZF#LpA4zvND7%$ct{ z^YsR2ruur+oz?#qU+mmJ2xIpTD(gQJD?B+ZJTzwG_dE6d4K_~q)tECs;LHy+I5X<2 zQASK%lDd=;KiKkv4UPEWAn_wlg+B_=>TQg;DrUshspY8^scTZ#28kaH68|Vj{F5N@ z&w|8{H`a*Z$dTG$ zX1uCD6CQp?w|qam?)T%b`?ulYw|C2r!b|=rURIwE55E_;{4CtwYUd z{MIT=3#W{<22;j&!o%-Fg=xdX-G;~S_V?l8omOG^0qkkx;y3!o@bHeRaC%xE70yVT z(NKlo4-)^G3jfKM?Uq0K=ed595a-5(I4`XrtuSqV+JYeQUxLIR1c^Tk68~?I_@l-O zaY+cnl6b-VYk2q_g&2N+C9T2tSN<(L{Psc&=bW_7@r?a@c=){tF`RzV8hqE~Kf=R1 zEyQppN^39^{Wv_lqe48Bc9sxp(rOwC@xOw^|0cx$^i|L_BfPKThCjmh@MrRVlL#-z zM0h2wHtlNKwKPwiKM4~5CrJERkob!rlF(QY)`bw%#f#;q;o)}_Vff~#v=7H$_kY8~ zZ!f~|eL-m-k7w)W;ok??`=Bk>P}t;0j()c>Qj9~*!_+VZ0f)E|iik#{{6{$O^Sw~_WwpXhci z?SIn#H|=L>KTrEb+Aq`L@$Mk7g{-}PVT$7#_ zUNSAtHIes)hw#U!E8=GLMt&1EyC((;%_*Ea4tE#DuC>;3UAOAil?Iz2u75R&u}@nyUa9%h%8$9^<)S@ENP z_2f;1d`uM|YhaMJzCtmkPkN%;#q`NRq{YKu+DM=FYp>->pOHSZ!S79^&q-gB@Zme@ zbD#WCdO>>OlO56*q%TY_N-s`dl=5`K=h91qNOI*L1(B2>l3IB>h@=IP^dJ%qBCUc* zM(l5#h6a($JLyZ)m!&UHUy)vxzA}AP`s(!Z^oo@Crmsz37eumxNb4ZdCWy2RBJF}m z`ykRGh;$4hoq|Z`*k2PyUJ4>zD!a@M|5|VAhUE*&mc{=*PJ_?Ij(a6b-lFga*5lqA znjc;vw`lIepX0Cp#e$jPKgHg&^>`hg!c_QjsPwt@I(#Db6KMd59Uk{_vEkog#RPm1x$FO{?zdB zxW5@Z)$oHpy>KTbIHq^1^o zpJ$|J>@to1OyBgMB#gV5{?j1R>sNkZE4}E6FQxx5{Dst5G<2{3jjd>t_}|!yCPo{6 zh(h!|(d6)jfYGPkvm*L*^nJ~XqR&R3i#CluA8i(WA=*5aKhc)aq#)8S_LIetmxDc z+DF?*J4A)gh#)dDh>Qv%ql3tpAd(wI#s-mBg2=caGCqh*2qF_>f72hC6h!3dl*%si zienA(%{LJjG;KD&s3`o+9IXy);m7;d|C!h;;NyNleO5);8{aLMBeIsi@ec(}pIcbA zx;XBoQL)btD_$LYJ8W^$@C|SLxw#c@eL93}d-yv>!;Ag7;yDlhDSJ^x@oJqnEFkXJ z1Rf{H(+SO^Pc?Zq`qV=?Chm-hqMxrvoEaS+9T7Y8tsnn>J`rp=^=F4a0-iTv>h$Tk zBOgEW#0q#irt0X^^)pX>1uut($GJFm^81|nzK1TZKexZH$J^<2>{V5u1BdpREsp`n?B)hsND@_*1^o=i+aBV0dWU>EYk3MVrQ-J}5jiuK4G} z)1QwoesFkrT-wJ@Zsycx57WN>TrM2axVm2mH+~_$?xEqKQJh7aho?7><18{PJTyY; z4U#u@ZsU?Swr$IB+m`X$W`~Eyot_k)o)mxj@bFNdlOGk<(T@d@X%Evo`X|3Ow?#i5 z{fma_`B%|@h^1$0^b^rfHaQ;s)bkn9Pe(r!{jALVT=etNFGRl>{ZjPHPyQhK)#%rv zUyptx_NKq+x1!&Uekc0fcN@j>gUF0ZhncYta*50eBC~_YoLGj=4I=Y`NI?)O3?lP` z$b!lb1(Ah8q$r3K2a!b&U;7tX97IZji0N=i&?F~T#y>{C*W_6A`_H$E{!8>EN;rjtSSFK!Kw$@m=dSb)R z!+hgkPkQ51Fz1D*$F+bKZ~T!F4L$~P?t=JD-}aBye-Pz!amil)8IUjh%D;J~bVX78 zESwj9I#79j>;qo>cYK?f`R0d-#D4Ae;f{InpVirL!}@pHzFFJU1|O*TxZh+yL(`w2 z=ud=Z8c{htCT3dtO>b^}^M!`76F=$1PdKe8Dt7t8v?YCfxQjaof%g4~;wfv*FpFjXQfzcz9f7ooN_Zu~Q%S^LB5L z@xEAYq3Yuw>}iirEYt`iSM?+zmS8a&o!yf>kF z#`_Y^#2#z+#|k;%%ARUlz5P?|o1L=$n$LTv&1e=x4*tqRZS-4Dd@1AI^$)eCoqhEW zwHYb#549QT8PNt~Sw?0?pV(OTLPl0b>*hrnZJ(I?;{GS6W^`Ti zUA%8bzl@hN`ezKt82IFrjKQsLWDLz1mXVz?JYz)0$c&tfQ5mCKIrmHuIU7XI1(BK{ zaz2P$th^FLE`?sb97OzYn_Uee*Mi9PAaWy!+zcYO1WWjm5AC9 zE#hYqV?}9s?g}%}ii&Z@k>a8mrRDYiBd?_XWyrA;S3uo!?VS2&(@AR7@Aaa32oE^U8=TXl$Z`^47%OvaTeayQg-$baR zdOOA=M1qg{K=7~=p8b{27mJnpn9mn`c$2YDaD4vhhZzulgz@(t{Rxh57TQIjUE&KR z@)M6s=-7!}o!B-0M7DbDCy&QY?B>L7@h384btCREqq`Hk$K7Mh!oTSw#~YN|!-+lO zcRcN@5vpr2D_*Xj6(8M+U#suLZ+y?XNo)UkHlb5x=U6AM48LVP#(yI9!S~fz_I@ph zyz!RIjK7P02-?$_d*fexC-x`48$|9kOy_@eQ~#83HkQtHvHQ92@{N2&RK^ct;rwqw zSUpP7NX-3!X5c{89ajog#ni zQkl@_#C)1d+cAo){BEJ`qGd8ASd%hthBgjMC=1bVmTVVu58v@ ze+wdqbC<6yTP=HMFDPAIQS$JW^9?fj`Fa@Rz85&R{@;Xe70NA&`)iXq<>6)D_#R|( zd2w+ODq`WESG+oWH`4g|tCtox;?wKTVfyD`QvbYuB$*uJ`vc+Oae*5<_ZQCm#Y5?b zr=sD5k9($!o%l;9{_>4y%J59=J#r`?SAS+^LX&4R6XLhT`5!*cZLt%ZII+n?w}mHC z%7-66KE+ObA_h_B6ZPX$cEI6D>jLk}bSL4y2~UN+IOd7S#~K6ld))GS;(`7nUyT5| zL99)#Y~(lX&c%M|EC%y?mb?XXlgjYWsIxP}Z%b!B9Y@k1hlem}GEa)SOs(M453?`x z%$pyNSO3rFpXhce^FrpuAoAHD@`VP0n0dwJuO^&}1!C;SKO>)W{YL*KOy(_-n0Y&h zeEwG=G4owdd@1un{k)2l&&GeAZ{{D^@&ol-elfP?m+H4{{iqCn7!~!qek8W*M}x?h ze|6VHyI!o{)&Kp4`XtW$_#;1#@|D=HM`nI9;a=va{P(B*bL`h6+tvSi!c z)yUWVtG7OmGV}A9Uucl$nP1NQX2OTBW_~5}tC?TR{Ceg$5At*abel=1*I#B zi(;ue{EhD%hMx`f*1vxkz4dsFhg$O<4hDg^Q+FR z{}#fChiAIkzk7Tz$IcCZBRlhJVK6^D`J3UPF&lf@&-{9Wjp6&P$7Oo#OpoN5-)L~= zw|zANrUu`xkDdAEhqPv;Gh0?PhQSTo ztsmU!QJd91t3y`DtWL34bNz-t6TW!sZT^q&?Qi4PRK#9f_SU~`@Pk1Q)tU8DR@a0N zU(D(fME><+R<|JXgUT-XMa7HeuUS_A!Q5HBvhwaV%}Ussa3G;NVSmEGgu@-+*q(3#IHwa%CF~T!n-jK~;m!q3%+OUq zllKQrt_4qgC-z~U(}O3L22EyHe!Fs1@Wd;z4^;SU(4;ze;)USJAH`l<-Z^Me6+H32 z$~nQ4e;G8%4xYryS7X2B9Qy_CRh55Gxjbmn!=N@fi2PeUq%*Q+PMmrLFC{4sp&nscJJD~{?8U>%`a%$98&XF%wJqwG;zU_;)NBlw*fA@l(irR z``iA12?+`Bv68JU>m{p)HNYBbO}6r^+1A{Ig!hfGa;hXJYJW!U&wR}KV@v(d z{JHfP*8j0SVg0rBH`aHo|4vAF_BpGKHOeZn)cEWb%lXfK#rn5|gy-I6rCMFAk=6{W z%-U`Fv(LHabKkb$)AR+)eK#F%Ew$j=^sJ@Erk}Uq*z~6f3D3XJ>T30~hFh;#Q!Uqi ze!jKDa{cGw^t}6h{*%^!TJUd{V~w_O+01#(?A2_dHN~24xkfXbH^Xx?*J@T|EwXUl zjAojtz1eDOjkV6&XuW1tTHCCh795)Gx9s1n%ED2zYU{M+ewx)-_-TfpW}mfaq}l&V zNO*xRUU=3@vwB>W=L`0DVT%=5_IY84MHesF>jk=afi7OK-wSl{0$sdt!MbkY z{e_#>Z3~Vs!25;IS>Lx{()(C{{)Yh9GX&0tp||~JhiF<8{dmjj+iq3wv@q>S zE>%AabaqCsu!}>=ZeuIwYm)%@I0v&$3mv>uw-3EY#9p})H=Oz|>()-nyYyR=w`PSd zDQMMbwdxK1jyV#Zz5U@@{G`E2xtZIFA5DkRa!dP7q>quT z_mWn)OZlNzl6v>1FqdSvK>6u!2*qV~hrFsinLlwk>iQLJyZ7nvDkVg$<`&hP37y}3 z>%7oiDXlkJOuEls#yv#d-AcV9T$G7r}aYZNv7)KE+{)wu8nZ0*J@rV+lpcU;8U;b&7)H%w7&U zFVXK3`r4EHA~m_Lb;$g=oo-R!lSD#cfEID?#a^Ze_W5iwQ`3vxyrO#?28AcqM?c>h ziex;WO<+R2P`oR6qcr!Crc)sNLbYs7UW+)ZyOXDmXa%Rqri7lV( zqL!BAC2b&3xik-F_BRw- z3+BEYdBt}uACUnf>Nyo0d8K#QA4Al7sL$ZBn}G?WTz~*^?+#XzEYbshHvTb4Q4IH|FH3> zxN;O4sbIG8SX4DH5#V^+$9%(1^r$M4fBIviSz$IuP-PstbnDo7-&0fxzeF09yn01? z-Yfh#(YmbrDKH!aKZ9X0v9G9_|0_JB>(d9Wh1V=fi8m0^^DjCSUEbzRvji#Hq_su(Wl`hdEu8u@FdRC6!#4T9*F5P-^8{eB2)9H}v?Tbs>p&k$pqtVkx#Qx0r=YYcNCL@@bb1OHP8_hGkMiPaM#6Q$z=3H!IF;WxCtb3gm1oUr!7} ziUr!uibk@dcBL?AyA5kj0sDWG#Ggu&!=UXqY&g~b!DX@{cyP$vtnG_jW_3Pw)OR>7 z#tiq&McG4-R7!66cX2wc-yHYi|52pKmD18ix2Dn>&2eBC$hettWfG#4{%dLU6kZh$ zbP1>oPktty9S!-amt*Jd%7Bz#cm98&q2V?>{v%fDKOaY*!e8E?tG zcO6gTniw)Luc>I)Gp}=@n7NV5N~4@acg8oLnX5@J30LIC`ma`J9=Po^n6Gj{cBtpBmEVfg%u{cd;HfOg(Jr2 z@>Kc9=d%AHU41;Ad4e9)7cg`cT6MfFSG9Knrv*ExK*VqR4uOzv9{f%ogzbiL&~}AF zWJPl5irq^X*H3uT1kM{~<}O)3f%&mrUXO;3Q_UIoSME3QEmkue`F|m>ZB4vMpX#z8GLa9m%et&i}oU#q3? zn0rF_ZBE@;V&)f``kg;&z}y> z@440g@NpypqrdMd%uoIS&YT(hKvHh0h*!!-EibdQ^-KLH`P0#a_ltrwSPX--Xcr%|=win%J5OMKE*9+(CK8RcLux zsSM??(qA#2YOI2DcGwvoa~(7_SQNu6*Jt%Rhg{l`+}P5o*o#vl-M-%RH4AMU1L;1N z930nKhj#Ps#Hnq@9fWirHO(Je4S`Q1XDxZYU1u|gIS8D+Cb2aY)>uCPzP7)5Gq!Hg z+H$e}f9qU7A6@Xzrbzfsoj0(Y)Va1Z)Q2K;|Bp#j=z_FzZpvQIB+!Bm})!Yj0| zXm|xaejD0T3HL5VSWxcbp_?FsMsl};>R!IE<@Q$${M+g!%6}n^MD$YbQlU#+s*oaK z1KNBcd=)7?3F`@iZ+=1OuHL0zWf0(S60E36Sao~;1l}-#nMN;9(U}=%F8eQRpGnqi zzjgT^;xy39cpWy{={OMyh7H_MplSKl z(+z{^3Q+1}kLkre9yBCZAs9B%RDDE8L2_#$Y`S3}{Th^FfTkhnsd?j0Sye#xKPwZu z7nH@BD-%8>^d;K(D*h$(8^29TO|1ax9jFLg?Lz>|`pJ#(?0|dCH>FFDD zf&Ha6<{;0Zh->@n4+TJrf=vg_rRV-E0{VvX$Ewyh`5? z<6yQiOZi~XQaxiww0wOEJ2{CJ8;V67q-yMU=LAE6>SI&vt4F)_0=);dO~Jn7STxPY z<7n|~7L20Feze<=r8dOAb~IdSC)^N4O13^I2W?2JS>;zI_bE%xQSFpN_9}?SR!Iz! zxThhih$a{XyK^*U6|#fR^54h#gg{r0;HFHK4{>PQ-?@>q6=5VWO`z7-J? zLIs8FvOk^sjZ6K0Gy?#^;_1P#X!F`t;7ipR`?xbp-1o5zi;&-r*al866ga(3yHMcp z+X~xL>~Ay{I%d~jV$U3^zHVY%AN?)J<2T(4>)XGUjPFhc3E*%HaAT5V~vgdkBj(kc}RjlPXWP+1_^k8F1P+-}`6K zt50h8p0KfRHmF=@3!RlRnPy!GZ3&e6DJEqi!9IHR0%pD?P$fnG0#>cys>Hf9>8@r_Qv}4UtHjep)B{Vu?!-@2Qa@Y^XD|)SmMdazp>x? zy9ukQZHx2!M%i=58Ugn2;j%w`T7So0(g^T)?@)UKemT6^sf#ZX5uTzD(coSPxp*92 z0J|8?K0A2h8&6LE4QJl7^*0scL zbmur+0@cf)u?H~W-b2dXgxs-Y zP3+SkI~OR2_n@u4U9KO7kNMDn2wh)DeR-Y08agGFCXkP0ix+NNgj`o;#|ebbdR^y_ z=q|D@cTO?2rI&I^)&O-Dp|+WOEU|6tiu7#jyLE+JR8@G3T}oZ};DMAWK$nwUM#YY@ zGUl5gZiu37yHW$Tb&|%*X&+rH?Ql?echpF7U&xsEpFGuL(jw-^_ONM8xRHMt(NvYk zc`;ks+rCdm^#21r|hm(%K8xsq0b%cl&gYDLmH zQN+jcQK%`+s__U+=K{;PMec!O{1KJ`t>pa7DV;zLj5eX)$@Q2aWq?@ZgIjb zF&9CxYTNTsoMNr+^h{NqT$ax`j=-IGqMALAdEE(@?LVIe^!Q>OS4*;BfcHC7w|Cr+ z7Yzh$hc2q|wnPYeDn3*d9F}RdkbY^IUyGl2(&f{sDw0FRq(=%nyy z4p1+eO1;h3oN<45!2bXzIC*f9HzwhdVBdW~yVfV%{CBTD?s@mQhW9S&aT+PtC3-aGq&cjprWmN#G_z`k!o_j4OUDKL$WY+_rk64WLJhVk;q^;BcR=JxXC z4y*O>s%h&-#a+#v|8ZweP8*#IP>6cIc4uE8Z~ECTuU}i-p_?1?^~di8AXbu#3{=VTirA31cax@Aq{*CF--Oji>BFlUOQT z2VCo$>{8Z~S8w4HAYm|HBnq^wUFyj@v7pTt2?HhOTSc#dYS(*4BE4xj7UP&aG@W)k z2?-xNVA-A!@OYtzU~$})$Xx4v1IS5Simd(CL&RC6_0W6th(#*bVr!lIjeI2deMZs` z3^5JqI)*lV=Kl&+8?sA@E*JB}cl(y_O9I8m-vKh5CpLQ!vBQoczY?>~B0n)u=P$n! z<<6l=6MiliAs&o?&4`NebPRFr`=-BGR+Q0!NveYJLbP0bRM?3Lwaa8o#&|m_FZ8>w z^60W!(&NsdMH3LWPOc5aQ4AZKsD*4k;Gi|;I5a$jA=g_)1415^HRQm70b3jgO|4nT z6iHAESyVCTygl+qKOz@M{}A?iRQ%<+MMqwE2l>t17QsuA3sD_yxTa_-tKT;_4AAF~ zj>paQKAUq4;4O`b@19%q%^;O~`6|cgcWw+Raw)~QcMSFgj46VSqaug_ShhKc6F@Q= zPwGe~cTfid)VS9`9mUoFLKG=|1E4y66cw=+up5Iv>7+Q#O%VdF$0k3RQ%#OemYcKd z3pfHd9IJquxFsb!F_?{Vd%$WZztzgmx)XE`In?U&+-bVqza|@ z2nVpGy!21|(xg9NB|I>5Za@9YiNU~$@Qm--K;dSmvl&9oj%TC(gS+tJ(bZkZ*-7Dn z#dF-5_j9k#$ev-py7l>zukxE%p`N4C=+#*>Jv5c`{0-Qfe_P1;{|XD40+EUjV)&KQI3n2ADUTD_s3Rc9; z6{*)ccwB)*&ufN(-6w0~f#O69LrQC+hJ^> znPLpW=~y9{P-;|hH#}MhCZ86C?A|@aeyY1caqLjuG+eiXV0)-%JVA$^!84Y*Y^6Q) zMze${2JwCT^u6@E8{|#J<7ef<^z#8u-5>8RrTpS@`ly^=VDquz^!oqPS>)aIw40HL zW)~*c{1nyv#T~<5e#05*N7H(rTMPqSm{#*=+&@k$p9dOx%F&r`{=-6V7Jv6pxt&G& zpW>neQ(1n1L~5&zVSsB9m!n93evx$%cmC%13sAa<58{{0k_l#6orB-7?=Qm*IU~zc z*bKtjUzq6T5eoy-oF&pfBIsk0fo>k6@LP&=OeRxg;ZmxI2cpPMWJQ;_S6Bf^^v@p) zNjpM}g>WMc;?r^S3$ikuvT1PpX?Qr&NmeD=9M)m8II>-xMXH1f0@vdU%F=Jc&B|mN=i}CWA&Qwu1g^^$ zi^DnjE4${m+5d|=Kt`DdX;7QLZ=rh$Amhznw&|HD>_K(B9qUe=*M*bF0BYdBikj+> zt@wy6Kw5WhDj-PMps70)$ltIzlc+fX@y$ex0_ytPjNp^qhF1LI#1<*`aEP>&0kYT7 z3ckNNoQx1&AkRU(@3Fhn$--}v0a^XjkyMY4E<|x)!@}IXEG(6_7RZmq*Hej48P9NkgH$YO1tL)*_N4kEoYZuww6?(7Pwd#hfQ2@htkF#;aVzy+QdpoFhEyNO z^~qpo&$%zcPOXoG3#3C4-P?yY!n%@I=won6a4j5(=<^hinQUVT3c9fDut<^F^$mxm z-oV)pEV3xcYhY7vNZNM|bIB@cM9U0IX&Cpf^U7*S^fpsj_5f{S%2z#PG8GK@u|KAC z`O~`bGE)b-JNIzCYVi*Cb;+q^GVw@3TQ<?Gb)kx4lMzk zQ4Xnn4ui{LKFwm4eM15cv?SIr&$?rOZ`PBQkVW)F+NnUI04QGUgr`tm$LlQuT7_lZi$Y z#8IApkHMlVcV_g?SU{Gzl|3d|X*)BtQwboexzL~8r&N={kggOctEuX<>&Pb1H=C;@ zm6n@NuSCQy49xI?QghjRTQ+!=GukG6W@I&2`|erw-J|s1Q|}#QUziDs;g@4n^Vvcu zt*5pL`n;53l&1qUNMTN|g;y@L+Doi1EMa{P%s1`Ij%D#;azHm9Yq@L2bdnVoHdifB zy%xtQ#Z|S=>6lKRy#V)2T63I_55XhVVH!_%MgAZk_vyY7nnmB${KkW;XuKT?)%vtiqL0~0T z;c6eEe$Q7SOLBoCP9?Sn!c!zA7t+W66_q4R$)5f4UV{>0@}2;JN)B$UTYw#B7*MIi z$v_x3xscg*@T^j8vl4R#>NP)SbI_Z;mv3O^Gt-=1%jXqMUWcBC_MZ&~&2TgCLe8j5 z&XCSa2foQLf9Y@fCyBGG_PlrCn*)oNAd`zbVV+iWmMk2PEf>e2*{0Un=xalL59>_n zRrn>&q3(yurcZ>KX{lkpYC&{B5cz(8(>-x z9_DOPkSylg#B7cJee_X)G(Ev)M<-sxBGr%Rzt4R$Z)qB%S}HZ} zWiOtNYq&^$^{aqCFv+j5N$pD_#=!4^cf&QmlNMebRbCfr-vWwReS|dp46M9p5V>A) zQ9rWRH*%)py#XgVmp<>xsPA(9*4117#K6+l-gG{nGnJjQ#rZNpA}9S3;QM=b&cVK$ zb*8q)rthjBRiNJca}M_3RQ0~B%Srwm>F98IniEHS1+^;ClnC#Bl_HkE*Dl+XvTzMk z)n4xZy~L6wzFo?+L{fY-nG?71DnqQJ`;}?^b)iV>@)VD2)Rk9_mSS_9n3{G)Gcgk9 zoa6Tlb1_X$N_-?4c280wy#1AFb&9|>YVL&2ozJ&#D~b+gX>TE}pdMagPi!vvvHapJ z^2)fx=aAbKFLHnhiyXI|#ao_zfNBpLwz}MIF#ZScujlMx-(Hxz zqAZzBz0#1h$aQ+IFuB0b<06=fQXU*z=Yo^E#gg<-N!f4GSu4d|q^t)QbLNHP2I0 zjy+-Mue+=XbxqHAjI|l_Ra+5h} zVEBsZx4$^WzMs(dZhLNK#uS>jZN(Ikx9N13EqUnZ`Ci5FWaV&>xExR{(?0b>avC~m z==k}#RgtE6JK;IhY)LKomuKOg1HHQkljqws?E3u=0UfgQT}yGl6CGMXUPaR|OT`Oz zOKo7s(L1}%mt2iWgQL>>R^ceS3KYvWW-HHb-Aaf(sXLUumjO<`O|lDNpE z)Uc!@zzu69OGyNUxb@w_ULyHC#>rMGS`6`-SYsYjPe+0|3DZw3_o+=cFT+dXo02Kf z7$>OGbkdyLzo=!gD$|ruxOy6R%_PiQBAhWPojI8zh_M@G0+cr6n2y}f_mzD~=a8*` zXel~e`8VRC!{rZ8c^hv0`y>;$*#iyHt#}S;pYx?9uQ5zDrv#g|7>MpVoky3=AUMjs z&g&XC+y4KAIee0CNSrNY@vsXJm7yeTXr)Gp%*9Kq-KE22)FEnTKT_VE#Cs>6!Opud zDyhful*IyF&V<2dslm@4OWJKc7ZSzT+HpnoynJyXS9)9`)L;xO9#j=oh1sf=M*Wp) zh8Z@?A5D~Y$=f$xk`aZt-#$Vnn2OSfi3<^l(m{3}%(0?mx7+AaUjb3O18v^2AG35U z>;`e#)1boXMBk<%pHA)6%Atm)=kBGlS*1V7+8JdDKvA-5-ac^XK)Vt-k4dbt?2Z>5 zK|h}orR5|-2enbF+T0emQO1KOBB_ z0GmW!voMO$;kS>+z(Q*dIFKAsJij0UK<1nn1%SdxM!uCj$HmJ{AK8s(VH}OX?gFFD zkV;(>H{?h~RT`r)C9Ig%I^tW!?Osf5afW;|TP+R8JExiJ`4l_3S5BIhUMov_Q*2#f zCRCDSRZPS?^PA919Tew=vhY6V+DdL@@hpm4t=MkVIn{_Q!uu%SLO?9yk&;-C9Z*R< z0kh`KExObDOy7nmYb;USLo}|P6iGHG?(5eA?nG!R^(D?BEhNPd)!tCi&S$k_UwHY6 zn_n_kF|;=CS|j~XVvQ)!N@=yn&KXi7WF{J6s?;kI@cpbBM}x_kET-KTi#IqHikPk& z4H$=s6*oT%<1PMV!%>*NV(PJ?4+ z*GuzImCRxRCGE3j{1&huIhB!qB~_QH6SJKrdg+%>>4BAK(#j!KWm5H zA1(Gk>zW`NY4&Bbg=E@R6DYV;!Kajjq4`$BS4WG%=;Inthy_x;3`l`0n|lOXAxCKy zm7q)tC3EA!UO;hH@(m9A0Sf__D{^Q2!v@ zO{&eE9s{V!;MT{gE;c{jk{>J84`!e*lX%D>g5vH8cX`NOf>s_cD31f&eo!8RCg0Unq<{taGCwTCT)I5vk=?mnUJslx7f*!8)BP!kLaQ}S5z~l5XSIJ4x zRbhzy7`5|Qd8!M+v*(+uJ9+1-!jc~ktuDHu;+eh2*YxOydM)JmUx@VHymUw3tI_U5 z9tyL8@w}5iG}3KngpS94sHeX~e*nvOFY0+tpCi%vK}g?)U5$1p>jZL4V z{wKA?3XP#YxXaJl#n;-$Lww^??c)JIzwWq6V_kEO^mc#F!{xjC^~zsfRRa+#fy7<< z_5bH$*$olCh9Z2UHUhbH8<}PRl50Zu;E{FP#bp~sE)7hvz^RjB*=-SitF{prz&dvj z`a&pvcTX6z(y`ox}^ikOrCN#y-z%S zwk9=1o{F_U9%I4ueVe2sHP)+;ZW$sI>yd;>?~_cY#r1s~V@dBjL~~WA+pXJUf$6FY z9vy4;hwjFm_KnlM_;iSkjGnvvNu6ygr%Vqg&F!9s+na7qA0j9Asrgp4ID=S_P2_%6 zSB|*i#LeXDE~0qA#M^3dZ%8C*R=j57ZK(Ka5c8DV;7N6}C-aJ1Hd_3!Au zh0|>O^pG#&4fSE*>J*wtA6H`F)4~&A4^f-H><2KoY zm$~~{ES`VN+!>%U9Nla-ypK8~aAAqrlOw?R(pF^YW{z^CwL~#@@)YIs zAhwsvRwRa36}c6UR}F0&^SZSU8lztogT5zrR2O-OhPV-trTcBhkorVW_0WJZwaa+# zn~|Dp-btynTe0p`k%DVpc4=URk)`=arIF>taHXE*geR(R@_Tf6)!=}<%t}?VIL*y) zLVnU!2Iroe?Iv3hO{jwK|6EXV<~ee0!^$6?GFD0N-`JE73QFg$-Wa48=i2G5dR7FX zgX#^U{Qg+K=EMfd(qmj-V}iKouT*DgMbKBD*mJBObFFV|{Ch@r_&b$IubQ*3x0qU0 zda1(amRvgFcEKR)0_#ACQ?_74Zac&tm58 zIDN7^es{wEmy1K~BOR5#K^5!o^hR3SX&DCotpR!^}Qj>z}=yi(0z%W?~xt;gFhv>Cmx;DFNR#R0BCsbK{h{;tV)ivC)5scJ=~rZ{XK$4^^+)55RnAItlbAwt#jry%2YSE1^+ zpMvSz1#Yq9+XILEP(tXODwM(xpVV-HZ6&H!RB878J$3@VG1|i%-C~c+BSyE2oFqXz zLx-S@RU!txRaIR-f*Z2h{^f~0F2Cjgv)chdCn$SfNQY_k?wHOF%)0v8I&A6?lv;bu z9!5JsFMLB+R0G{$BUM1P<-9_)cOWw3r^_)xRB*fJJzDw|l6xo3bo+Q<$YFc417unS zM8jyU=>2^V?;6fyo2+o)=q3=mEEBpi7aMf=J$7}w5S&#q#YhU;osL)wlA2~D1Z{>0 zoUF#59Bh3J3Pc$gOlsO3Y~=*4PYUC<$%6-;Xao9&IaRTv+l6*nX4@_9S!VRN`bYMo zN6tUltB(TftoROyOTlVW2CxI-NN~-HLiMVG!Aitb@Xr-|`VQ_eSf)2vW;Pf!72A2V zK8`IufcP~jZ&PQ3Q&y5K5BeYnvsG4&tN4>`3YO6pe+IzcJa^_8(458S33;u~zD_!$ zy8ik=y*Y%@arrkEwt>SlO0fI={9nE^crU-^Re6nN*aiHHW=6)XG=^`Ae+sh=jGU=c z{^;G_cee45>|EK={ zZOmZnc^B$^{3kpAyO9SbeE%YW%1AuC!4J6A$=0&;d&z&1YRxu?y8%`H+M(eLc-N${ zt8%NJZE)zub^e@ejdhiCO&U<#4K(=^DPF_Z_sNOckLrls?N1H_jX>CY5B5UZr(V@t zKJ49P8th^zd~r7-j>qZPuHKLn_!X(KkF&1z`|0Xq;?A3dju?TNpd)DgZ2;(XWQ1x2 z@2CnpX1Z-Fj+anW~MPi{Bb1rWMV5B?8Lgt{U05i8L6u zlxVBHAR8EMmVE3k?&UG^F6}9KW;a^!+=WBjxDchCX;fa?1F*D9DwEa$&ersl7`Lk! zZPtABDB8k{zzg@m%;84fH6IO%=FK=_dF8VBMr2}^xHP>&5{zT(vT_5|KknqN5({8T zOQ4vZrra&)uqvOQfhL)DM}W^yK(jASb0qkMzCfak=H7mRt4fgruhGFXD1iQjsr^yz zlCpD|y0e5zQCaS;s)$c)(MrL1%;x$tB`^!Ja~;a!-?_MaJ(_QB%4QpIq0`0_kRKs{ zBHcrE-J9f>iR8^i+DNZn_uvi1-t1c*Q zUkAlPwiWID0r?$so{IV>oqhJ5)?U1vo#d06v5lH2C4Ew-DwtQnhL`?XriGO{TjbnA z)M-?Jd6PT)VmfIBbH2eA3j&=aFB%`6MnHdZ=Sjye%$zq-afNRuBPe54KSf+WfKBBM z;;J|CTrB(x`YHhWmR?;kSX2_;+P*41_pVp*P5jjgzlRI(0kD2o*wy{LOGk(AN>p#S z7zC^;W>;rDC0sI7d7vQNt0dfOFYHJcV8w*Kz$c zn0`-e{O0P{>7eJ5fDc*m(#yAkGGxISFDA5nI<<~wzV<429|+Ul#2<#lf%KOcgh%}2 z-1Rq`wV-NRlESJy@UP@}XO+ULcxa23OovvTk`_*w*s3+Iq@^P4bov*1!kwv4YSXCvQ=7TTQw#&h4eBSsWU@(Z1zWl3Bk(z#}(BU>-r zItr4mj#RwR(;4xuGV3=ku1#Ys9x*SScBUB@SDT?nHcQ5JUqq9fYmMF5kjAh|-C|>K zWnE>)JHvIrd8}c8S{TU?+Ie@+TbrQxC;~CRewVs-_h>G`l>En0W=^Hyl8T+0N}fYz z4$W{YNc#j}M=*r$+zmRAMQ1Fm;L7~c$_%JGPI#3SD8h2B0_7>A1V#%^naT9%OT9m9$l5u|; zSAP!$wdKvJ;6NspjBSYpWhJUR^cg!4v8H=I#>W|_vpfeUB5zDT8BMaqDgiRva7w0u z`kYzu@g&DX-W7P;h>}HSF2ScpS@RlctI0=tGs6OasPciXDb=YWVtkeq>G;B8@co5E zdVB7a&$==r@^2O*e5A>|Y*_LK(t3b?shnbQHiA2FqyQD3W-M|gM;9aI=pUH~yG#P34~k~Hx%bR$RtrCju+MQHkpPWY z3V~!z|Jsh^6%2|d5!)bUDv}uN+z#NdKiv|G>8I|htc{Z_4*;2&;1y~)wZW7I=XP!sXYZc5NMeM(x^GEwl8}+aSrV0%l+TH+YGewYU7iK1WK=-XvHi#9#CB`Mf5vIrZq!;s^H8 z(R>bX-5s9OxpLhT&1=@)mxZp87js-|3hk-ZsDCR+c3BAo->DC81;h24v|xvCJRCNY%2$WA^FK!kv_7G?p}hMy(_#%aDI^kYYhxoVKT6sEfj@Lan#j41OgYAm`<$jNm{G?Wr(JHO53%E|tn@ zj0}a8*133DirTm|In353?YQ)Psf2o!vebxBkV}t%d%ud*ARH$^$gd(;?Z<;+`xABcST}BiIqUrIV(SPAaT$L0y7E%lGj9zEEB7N z>W1B5_R>>DWdSc+5~TP~fLPD%whbGwW(hPPYr8}qVkYU~%p(^@Ep0h#?}m@SI; zT-{_%Bc^rDPYIeSfNYzqb7=azD4|e>i9YQUK+Y{G>z1|6*DcpbsFY!1cznU3tL_DB z;rs4gD4z1e3xw(BDb|ds*Lc=|spc_O)q`+lj}&qD0xEr6R8rW=;Moe%&{LTI$qKEv zmS}u%px_z65TUS0xxnVJzREg{ww7)z)wBD z$rqh^4O0mo3d9M$;F@lBg}uG}!1`{C0|mdCH{{EYfb z`1EvfDpfCK`W;!1Mwm}cU#Lj0X}bHDdtF4j^tur1^n7f(R8t{=@NP#hfG}L`p0?*v z7n^?Q9k1!WWLdp%MhUSA!slQiw8qh52q&0IDBGhN;L@w9Ibtg}`p4s@T}k!PLGZVR7f)Z6Z*Xs$lUMVg zKThe*BUDYJPS}S{$OiC4R6{oZKB-h~X%>Nga@hd$?WxaL@Gw|22)z4?ZDs1s1mAc) z14<l3DUmKX_lbg;)t8C7(#jMS2upO^9FVnkc$5a3?&BiJEN=(L`uCBni)q2g9lqfujPaLo`50y@QnJ->Yc@9IM*hei_DLm#Hp`t zU{v?(gk9D)T2!eks+E&C{A%j?RkGX8{FJt&tq%y+bx|-R6Z^UwanaW=w~sHm{9f`Ekx;O`Yn~jcl!a%8Ij8H(zYPJ z=+Qw(zE#Q$u(Q~&{JTEe$iJHm9Ws?ETk*=Zh7kwKHKT>~`cr;OjRwU#5K`lDU>4gN zwI`@@8b}F(1X-2tK=vCmY7Js{XmbbN8)~4nIa;G@twGifnc6s6gG@SrUtMimYcyR@ zV_9okS5qfM%mt;@d#oLNNB7CH2kf6QQ2#W>ZoAD;30P;@XfC1Mw^wgIVo=(9?Z7{< z#DHaEx1D3y5B!ryV1UFxQ+-NcfCm1CWrR*MsII*+6DU}Fv(I0!b|y-oYn5>~xS7Jf zOFF|VFmre&R$WRM@N|tq4f|V1PD=$UpY`l(qDFv6DNKLP3|XnVlTye!S5%! zp9k5ta-v{K%Km;rv26C~*V0EiBGG>)h_pU(5zUL#zUx}%#o@6{yaC6VYt)8n z(%!il&SsDMYK*(LUXy-{VSq9XI5zXM(Q*6Tv-rZUP_x3B#!Ej{&cN>d8GqK3tp;}1 zo%}jo_)i4}`-=lwul3KaX8sm^Zk@Z?aNHNn3i=uyyT4Zu8~g3(VpFVtk7h&g4}zxJ?qXB&lo#e0 zY#NJ!T8z#~>nE+`+YwBw(vyfCU{iH3Fsicc6}8fsZ~?9xE; zZPHkA%!D1xc`UkixgN89T&^%$E@uAuKvW7exW<$LoN0J9%28+b`KqY*tFSd=$CvH= zR`;kM*N|OUj+)Ye4JPrDf!2>Kmf1KCkojySN23*;dY>?VU@tlc!Jz^h(X0AMF;a2l zAQbO{D(ivj#mlQkN|hh+MuBB%A&H#8zPgWDM*N_>LZdV@n!aV`onY1tmy6psYA^36 zc-w>>g1Z85P~Yv|`XPKn?b3$UdxMADPu@>!?6S1T--_3m=DD#J{V9mI)%^Xch7?<> z@U6~=tA(FZAKEje-nGsDJws!4@b|uwRF^&r7k^4xhKUTGI|JWad0|fOI&y=+y*&D| zS_nBhQ{AEC;y=jEi|?&lNO$>Wf#Sj2&ZlKIb{M$WTjgfTp^<ean<}GsKSHF(^5b zvQ?pPKmj!Vi&Y+qw^!yU9^#+rfM03wtFu7N`cX^MJ*EOFn_ii4JlS!o6a_c-_L{1G z+VFdoL8~WQ{q0uwOewwy^hWDC3-wjXc3TZI{c)q^OLV4N53eoMuN#P@L|F6ZPkd{* zSWj)M;Hf%k-M0F9%se4#4Y}@*3ZF8y-e<*mqy)FQM||7y{JC%w~g)RCvl!tG%I1!Q}of4`!iE6TJZZJv*G1u%8_ZR&f5iJ zxd0wo)-#XPg5H?%So*`zd6joq|8f#{?xzoSkB6&sr(w_5(hKUPok@R9B|cM&*)$)v zcm}zghb|b)v5)Mb764-FB<0E0SC7*d#(wPNtGHezrF%|)?bL&L#`DwPJ_z+ISl3>{ z=w*1c`p}mY2|xDeC9lR0B=H05$k^&d7YxjuBBSl^+C4AsyR zPYsInsP1jF+ZIQ8wD?78Ry#UHCRI5)VZ_TH>6m-NFydE2U0BNQoe0d$UbnDWoulJ@ ztE1_j%I(K0_hi}^g&(WmtG3s*?_5qwnYw~vw(+-)LcX$(Zc*G8?Lq$`XcdbKR&O6Y&$!$}7A(p3N`K?8qBrw9lCMW@bX`qXBd54DyO)XD#IZ(iMQG^Tevo97t0v0f>iK`QPiKUT78ROD8-V=>8!?CnIINeeIKj}y#ZsP;b2&L9g= z=L(|c=#PkyNr0EYM+~Y4{DimPtJ;vhU}u)W`RlNr8DnX(I)sw|fH%;QYnFj)!J%itj38m5WaQztpQjzG}bcw2q>EBs`AV{IhX()1ffrXGM2W%uuEq-XX7xq5%j?U_1Z z@b=4^hV_7{jl)K&;$74^xQ~Lz-pTh{jsl2&r_gQLMxcBL1W(~cvpD^Q()5TWIma~W5p#ouI|3%=q%`NsU$Yk79Zqt~PCFa7VYF|LL))bI_= zo|$CZf;1m6t}A|tU;rt;?mi>QJMiNTU-12_jPAb}Ci$9Ye!IXZO={*@ybl-X%4C4e zzh+}^kYOjU4fICXtI1Nc(ZP3o*zEeV8y9MyS;T5pK+rX3CNYz!<% z*sIGPWKSg|Bx*Hk@2`DT{p{;-d*#Ee5Mi^i@n}f9;l~5LwzufX-dRIDh{k zUai{9gb6K3Qi6k4z2IF`{8p;0%}~GgIGgHI5SzB7*X^R#U~Ti`uv$qN1YGKmtaxU=|QuQVLN~Aj%@L`UbbJYPG&vtsAy= zp<-QXZQY@2TNOn_q}CO8L{Y4Y8zR4RZ|+UzZb>Ni|9=1H)29u|%$YN1&N*}D%yMRG z7oS_Sf9RsBEgxhJ{PgD!E_T|ZUpW2BSIZaa&;8KTcbV6bzPJ8NzXt!kTW};lwW?Rr zf`X`=VaxPK1`Zie`@_@fAI>c*%8Gpwl{Wa*4Zr8zCEmi7(vpV#SUh zRehAtr_EcYdb#}Db?PqvE=euyX!V&=4oIZ_zY+B%3^SSz2&Y`F8Oq+!0&@E$ z75e4wUovOv`M59UOpTbcK1L`Hj%oX!jqCc!O8dz7iArBCowj6ss<0?H=IPXxGfro$ zx^wp|ZO?EGB&UvgDZd>uXjn^N87#v(d3% z&(#O7oyg4ST5ALS^QTqMRK^1EfYwxZuC_b>N_^-_PxjD7B_IOlX z>@ZysP|+{xeqC1llf?9@i&Yil{d{WoxD8q!T=6pXneC^g@eAQ12Jo*L96l5fS zv)uLkzEj1svwBZ|k)7-LIPqYqU|>O?`q^WrM!s^by>=*n_t$l<&!>u(2$E)8trZO4 z|Mec%mHV#bx@PVDVys~Mo_`n3zEH0kKen(wIQ`D)oW1LR4Bl1UWzE?0>g7q(DpxI^ zcBV^9&!R+#1O4@SmyVu)P5#=vw!LrYHoo=x; ze*cpA8`oO6mmhuQQJ?yONBr#@x5B>kcrnK>{z}^QElE))JvtsZSk@vwKBslu#RnhW zX|v9V7Ro4y}&wD9ov zLr$EI{iEa2J6(?Zgs(jMFnC$FABQa2P}Z|JI<@QF{G)q4!; zd+>L5MXwnbU)?hg?naJt{GH)CAFfy`r^`*q=at#`xCD(3@Zk(|12OFznFH zk?xD8q&}_-DxZOW{Q79!n9QB!oigi7vapoep@xEr)Q^e-QqO%+v1-VoThF^LS~R5h z!zo9vzrQUPzFdw>4PTnOYyXhKA-#K6B%*;9ET)wjQ=&%;~A#~18B zar8o6s`JU~J=|vv+4?Z!%ZklI>MEY+IV{h<-2CDCuM4tAZ~levH0+}*oBo~KB&Ye~ zQKu3`1x|HsCg%Q9;q( ze|qyB-+Z#R^_7V2xvuw5WrsH(|Iw)=<3iVd+JDC^&YAaEvp)A*kMBQH&+n&Qzv=zr zHkW2g#tGAETi;9@*-5fj_;AGHt@B^1&L~S1!fV@(`byf4+BtgOlM#!5p8sS-$vELd zIpime-FbNalpck@eLJN4wLKGOEW5bA%V5vHVvl7BD>r`_G~z|~%FW`SE`z%td--RY*Eh++^c_7*Q^T{C)r2iut52DFZkXrw zjk3}D^WFEw6h0GPsMM#7UU9N8DRfzkesGWDJNqsy?7QXrBRld7cZU8kx^2Avt1lx% z=Y-GxxqC*~4}RUBL`k-_J`g%36uxJKES9#eUDsq=^Zk(iTh{l#Y|bui9;eO!`a#&$ z`ORDQ$QW5N*(s&9@7_dRc$+ZbzV-77h7)cIwWYlYy58)eri%YZ)k*Zf~2_h zs9}E{T#_+8A#vEj$Cafc7AYTO{HIKr@ItEayZZ82+_PEL-7@@+{nTxYU&)mvsW~V9 zYI?TY0l$)~t=xwkeBx4|d|bDFSGPZhK7Z9}LEg*IpWO5ENB$PxdV=swW!}79 z-Dhb33Qcy`UYezSR~xCBJ+dq%Z>MI%H+crly|djrXz$g+N9A|jx8*&_EUVJ42uTjo zWEM=)X^M3HXMDMA()3L$_H4{uxo2`(UgakEk~1kSuOvhs`p2^afkFKT=dIWUe^SGL zoI82orV{1nS(EyW`>}TN<$w1-P`2M?(vF?uJN@xszk8+gK9B!A z6?J&%nJ@0PXnSt^1dsdQ%vl|K^q8~zfX9D)J$ud=!zsheERXw@@a^`tZwls&i9I^g z`TKU;C;gIV+~<71T|(Y_x$WXt%~=q8YUJU}(o>Vwe)770_kQX+qyN>ioz<(hPdb$M z{Kz}suiSZI{iUw=2E^==?vtO7D|`XpZ%!y}FVBg67`SaDPm(HZ9%jJ6w=ZodFdn)I|ul%MmbW-7$U8`0-%&+V{ z`LtM_(=YP!tW@tKiQ%&z&#*k9#IPx>i4K;;rBI zd-kjIFZF!<(!bbq$4yDc{M&W2)}AJ2>@pVk+g`aE&sHyf(kPWT@j_Hw z%Ezk)oPW5nm*M=hQL73r^v%7en7ps_{QaE+4*dLA`7+!s&nI=oDNwjm&~!^Pw&XAh1JYLhbkz|0?8 zUP^xc-Tbw^bt%vH_DI>AR#|v>>yWC#HSlp-v3%OzouwPrY;9kawkYZ9nGHv_jxE1A zzxU8-se4?P_ckP@u5(?!)#K;8uWmmK5=D2Y>9k-_KzH>FNTXkC6?Gl5s&C|yvr9tjtL&wxa2P>6Bht@nkQ>upF zDRD|2Bz|&g$!|l$FCJFAD~ArLd2psQs<^29h`Kzh>hMO-2`{!+?i=)CnxxZ!%+!*K zZNnD47qzT#8-wU;a5%y%a-W#>burJ4->3cU#DAaV zjpJW-a;(lau1fg296mZPaZX78Wr^d`CVw_O?8?9qE`TV8Ssc={|P-Ys&-nfj34 zZZ{8wxCe}T_u-*`&4E9K+rM$^SUCHeadR&BIsDnfIqmLDSig30R^ZM7FT4wjytA(! z^@wbGcyUjUV?VsL<@1d6$E72BFS;1_!P93)Yg0cg4xG~NU`*58x9S#6@y#w9zVP!a z{daT=?Bj7YaaQqLx&_ZZ{Z;V6?$56b$iDEdbJR+Y<8!^*|NheJ)*>&%f}4MSb^rJ2 z?T^p#nz2w{`_+@*hYVh(k2>#hSij(c*S*hg*1lU1^>X1~zH^G-9TxR$K}>AbfOn72 z|6xCTcxC+lpzYxaZfieFx%}(sN$+3pmgHkBZaS>hGuajIA4YeJpY>VoC)v3!8C@T& z_+>(qwVypL{54&FZA;(9Pg@O?U+K4aZ7V~Oi(Fpyv&-tPeOCTCzg2qN`+nW}`F-=; zrAV3c&Vr3eUK{2Mhiu&O>5JSiQ%W`CWCuoW%;on-oBXNIjjedbK>m4)Ypp32nJytBfNxdT^UGju<@@o~SOTIB*@{ifIz zchcuw5XR>|jGK3SQ_7*G?i-)nbX(+|SzNVl1zdKzrsSe*o44U~+aVJktS-5*;PUwW zzKNSpRLxycwc?!Ad-utSGn`9T?7A&0_8wYXHTT@|rFFv&*Pn?0>S|Wkl<^4@x^|lI zaH?~8+eIfH3|%tYyWQ&43kBz|Zku*y^Xl}n!*>_;NSm?YX;^t?O=xQJ(}HV{%6928 zYqbjx6wVFXQ{O#GcVMV4roQ#sZ4c6tEB5dGO?UO-yyCEo{A-WCysVo#Xt^#iXYKJn z_qeTkRCn=d#gvtYV-{}P^Zl9&L5Zu2rVdy>Ix%hS1*b%xLl;*k?yA1{ed6G?rO#KN zthlx2_z#2D6jx>_Dj;c9`oevK?%&$Is`Ob#{)6#Xw?t^F%W}_j(##lM7g-Zlrc=)t zTPLsCbtYwV!RGv+x}i0xHQUY^R1~ao1v)>uk&fG=~Ona`KIL7BS-$UAx9gW ztO=_r3z46Rz1qSfKlM?sI~UHZ)y`04?t9U)rlL%zDc|t;>8Q1NbnFs2GH3em1XX-~5tj`Ls%X`tk z#^X+XUb&`VOIFzI*6qvVzmq4gk3X~Kbj9e*{IZG*d3l=`uk60RptRbzF8}az-_-@R zlWLOh+%40o)~eGop6X{GNtu%Nw7m7|%XQh=r5Mmv-+>1%giifwTg#>6F0c9S>};Q& zPsW!#ZBs5BJo<5wqVj*!NG=5D-WfBt`=6fA3%dO2c`$3!`kZ&Fman`c zDS7sGrf11h_&8Wm=keRj>c~eq(=Ili>eEBC704O^6OoG+gz=`)y;4J%YyQ?&-kwO_0>hS+g=W9 z0rUU2oAh;Pa<64wOG%3;x9x7OuEEXMxsEK+mPSW} zNK|U6S|d?tq+;wt6FxjZB@t;PolUTvr3$fRRA>9Jbyg}mvtaD}Q3Q!xD()Phl*uss z{!)cV72a8-4iFl9_vZ_AemrBaiF6B5Vv%}yh$uWnr3{qHBsw!59ZnWf7ZuQlXahmK zz4&PKQ*k1#5E_*dz97Y$m(AnmwK^qce3CdaDN>4wLmBTM6Cat78m~`EF(gb@MW!Ic zliUfSlFcSTTUcpLATa~+3W+96sR~w!!dOM{FEL-B1nucT7XgVv7ZE0s$s`&E>q&q> zD)knolK@l<+?1N?5gI=V0IdL$7+PULNEHz$X%gF{v0>sSkqRx$j`nXRFnW6PW->dU zI7FdVM8@gK&S%j8UA!S7E;2SIO&=W+nE+i>Y>11DiN}F0S+dAa^|6Su;P^yxiI`{; zXFq){71LNiR&Gn5dUlRbC{=QiOd25(hlo^QTEt4ZNUBf(+1HORhys$;3|=C|brE8bMiih_XjGyA zX4gk)7jg2{20rW5qxB1o1BY>qaU`F~ZWc{&iZaHL^nPqFpPAS=s0BWg$T`M1WMjzaGK4P}3%xOjOM`R~$}ojQMRpU0c_ukFjM@mx z$Uy?IBI2f-QmfE7)R!-a13D*g(@9o@bwZs=0uG8)soX;4>mrGp*aYltep!r zH3;k=YNL>ziU6@9wB8PCkIi-%0JhSPmrc-3;a%dyDUqom35M}8vE~64@u|qU4W$9- zBx0a#4YRWfMw8HL?C_9CyT~>?w{Y=tc-vu`L-Qz?6D>r|Ci4sVJ)C`=rZ)Z3agQUv zNk?9Y!!$Rs>)Pfo-J@C#d0q2fW0;07h!L@MiICXHr19XdilU{}YQ<616(i7RzlTr}3Ir0Wh1WYB)?7+4yM;R`Ugs&@$$X&3fhBt zZ6k48Z<|u92~kQF8rCiqq~Hst!dM9;VpBLoF%Tkk5$X`rPnZxX)G#Mv^AiaWTx22C zW;GbC^%k*EBQ(az!Qb`aWfT4)q9yW3SPx8yH$GYCA;1`ZR}F&O5oK%O66v51m3=GX+3oJ)_aVBw^cI;jpA0XEi$XUt^q zPZ~chCMhy0#t=`k5IR~2)`^Exx`ZTi2EArH#&?GE1p&QyMhOdp!PbzxrKl&qbUng0H!0;A{KMAnITf=_5MQ7LvV7=@ ziPsHki7wpG7u@mRQZA+B#K1uzT9we)IgBrm!i0AugJ0nHPthl!I~k~9 zagB+EMi$r`91}P1-Vf!Nlaoblu#W^A#l=AqfDI2fwNyT)Y59WGFkZG7laEwyUZ1E= zjgL}8S7!_iD1WJ5JvjE^iU2kAdr6p0j@w-yy=|Ozf^4k%DkG&ZhlEj(>F^^a$Zjvp zVv2&P5<^B;yYbT6Xu)lxO%4t`#cQ@r|A)~T+l>PI*Pq4yg=qa{(f}A(t!yWlVIrvd zfH-(uSZLxmW5TVCX%kk6?f<(p5jeCIx%s-xZm>zU9HPvE22oUP7Ia7GusO_;) zo>M@?rhE4A4}lyxZi>vCgcSjv-zU<)uv9!9+=Bt!<~ z0Haia$;4!4Frk>G9;8xgLtws6NhE%E7S7_o&ld=Vytz`kp)yv7Jj zB*zvr5wS4XEL8`AaEp3#GWI$?5Pb?EV53E-jFK@q|{CNV;YLcG?Q+b zHDR{bdwjv5LA=j08Jb{O|3fvxlKKIsW>{bvqah}9L$`EzYKT%T#ruUt3V6ENq9Lrt z&UV14jHHOADha->iKGQeP%Q%m0~;ZcC=duRd|Y*d@nEPkOm9PB|4T)z{9&?-mli;3&lCKG(a>(h$uki(w&i z@%>F0#>~S5QW9W@#Z4N@P^AXUAgvTa8a4ovWSA=j>*d&)FOW!hb6prZs|=PXhQo1B zr7Bz!3Y{F)TzcO7occ?LeEnLC*$fk>>H4j~Dz~W!c;hVEZvF;bCp6I_Yk!eU1f!iO z5SF@8L7S5OC9sbvLDF&5>|>9qR!~+1+@cC#kMK?@l3{Wip@w0LkawU;DL3y0lR6+# zVH>D07>3icG)hK_#iE!k*UTZE_yS*F-W+#UEg)J=R{$dUJ*%=OfEp!s6L%3o0uv%A zl4W%{aPf;J0pJ(P)JSPFM=TbPt|?yLcnU#0TA16Im^>UieL);O@ZX|ZcnZzI!PC^? za(|@^fh6fSiw~RsH+Puh(Bit=k1duqzv9xLAJg<($634=keG!{fU~DI=L`J&c(bO` z1x!7hiint+;l?Fov51^t|C4o44@>-g$DdVCNPZ$J!YySs$6gc)om8b&p+fSPDiM*1 zrE1tR4yTFNw3q`E2Q^HS5NH#=K(OZ$6-;Zuzal!iDZ@N2z_fwyg3EO_W7<^I2VX@7W<45K`nC;PwF9;6i&Fsa|!=yoQ$Qh=b*ph~dDGy3^ za~o2j05?W9tn8+ES-Zpm}dza1;jiC2k(v`Lt6Z71f zWx+?QrYzn-m&c~Yh-tys?qFWdC>x?w$}m`s2(?5+M??@b%tSDkSYRBKg69Nlav>tk zaKr>qdn0%`Ax-&${{4A#0_dU=snrsEp$ZX{In9|%OlF8i3CVPX5El~FHDPyn0=4); zCB|4PxY=f5!IRW1vn8jUH>63D%|Bun4&lAhhH`SP3p*`iF~qoir7VYXkz;m9p%)@c z4wSOAwKC(lCisjq8_p46TL+fS4g`UkBe^M6upw{L8LFw0pF-eoEjSgrzIkjlS~H&VaLdf4Q;Q^izCBnRy!VF;N``e zt7CKllOCcSz?9-hDy8tk7=xC>s1i@;S3O6`((~2#4i$y9i)#RCU7HJ=Qr{Z#o2%W>YL7Vsy zIcyu8g#Qt!0tZfPHVx*@2VjCZM9Mt(Xwm$YLkr$Shh}T}#rzh|lbyCSt#piOvZ3XO z7W3TRagBwnT@J&@dHS2bf0qNhfW7Q6^+tdpLC+pCh3Et_$LMWK6Sxu5NJfx? zvXibln*=)b;0wZldS7}kMo`egVc3c9#l>%KMAnHmc|Ag-Uw4Q!hLd`!CJK==m!iZ( zeUi)&HJLQe><84%(}SM2_-hRzjaV~M%$N|NB^;;IjdCGhkPIzgp0T%7X!l`MbHzQV z3}@wwb*xsJWOnaabY|9=kD3Xx-NAWvWQv4(5X}@}q9e|u;nyG#-=$cfjV>`MGCn#o zAzBkTL6@MP05{eo;1LI=+{yaXfY?ZgH%v~X47KAEV&GgGtA!;zvFjj@MV!tGD(la@J}6=XE6ph8+)mBhZ_s(8VML^7k(5w41K z0$d!Uk5*%6J`+fjXvJfVLE$OUpacz+ErEWvy435Tf}I-_N`NP?#j~X#g~=C-E*gh= z@df%|)*yO(KaGOJhK^8Ry=`a;1(<@^J`S0jlbPOqKx?ogot)A1gj=c7PfgY*+B4B% zIJml)L}hZ)1Xv20fVc*P4pBYtzIH0nYbA=EVN}9imrmK^_JYku_J&xr4%`24*<^=9 zlN~J&w#ak4=DNB0M3)q}`7hR4<+R7?{idmo=b8-S&34Flh-R;iIYc6Y+i@Xgpjv&sQ5p{_kPtrx;zZgp;D9l7^ z^DT}B3PeFfYa0p&?ZSTwja~H+*$DJ8ZW}_utprA)U=!>kkz64#HP$~86&928;)4YAP( zySq)8E%+4rr(+N=o4DbCrN4y6=u=<_he#pPcQoG*V53ilN+n^Y!>ZA?xJ!0UJ1e8L zjqH$?3LD=g6C!~#eBjJ`48NL%SnCsN7JFqOHHs=(>k_bDPb1kP%x?7wG@2*jWl!d8 zG-psSIh)7l=qxS9&P`%Fn37NJRR;U2;%KzFy2h(QybieH1YGH02Uo01$QoZFZ7PC= zA=YbY97}}RACH3uyThE4x&J}xKIz#j49xIwiYDly;?Rpz$gWDBfx=D&ePXXdG^kPz zCZH8JRyAN~50JCoJdxdREU@qcV4)MI&QMxoU7$7$E!rlXO)dJH_5OdrPoYsV2Br?+ zXfT&VNyS4IEfAjAR2i|btAK`#8y#w4O1~Y+V`k5@!=2)D0mVwBU*@-YojKFs$o`2xb7T58m zz)-eeXs|%o5v9(;>?ZB;$ibO>uGX79ZI;HH^E%pUyBYEyYC48r0wLts?N^q=fm}>6 zk8=bQv>tfW$E^3bn1H%qEPOz-JJ@0c3j7an1DXVP63~MvLq#$zdTiK4B&-(L7Z?C; zFma>}kGUbfn@^IGnRLF6Z-*ZbofXB!SIoy@;U|{d-WDHCp4#7!A|tj~={m#CKdG7? zBF_?W{o0s}H4R(IA^B?YMhVW-7(FYnEi8-$@cyS27o$lRhK1Q=mWM5dlYsw#(lUa@ z*`;9CT1o(fIS38u`PArjGE$w561N&5vl1 zIjtR+>X;!Kqcae8iQOfG87qrv$AX{G>jAJECzJ|VI08a42ub1=0$2lx;DlfcG;*9= zN%aS5h}Gy#d$gm#WL2EO5F`N5Y&n}xMsrwF;IKNxQb-^JJ9UBGW@0Lc&qNV0tqRl= zjki)u0<2U@t^aBzm( z9-G~Ds&ky(;#;1=&Bra2*Xe&HvH$Cdn$^#pFYxx}{SV_Z4AK-M0~wk07S}jN0yJhA zV+Q__Sm`dPR1H47NQR7C$jK+m5U7%b!ZlBDsfZ}fC-X#50|Wf(LSrVoFjaF|5d05V;rV*`k!%{(8TmD~cK zvV6v)J3#2@kEjls?9Qb=C{SZm2*(c+hZi<9Dk*+rlN_#UN5o3jqz(`%{3T)uyxvC% zAT{R)OC%x0y+CZm)qG6@I^;B_E6UfNP}GBunAS7A#~NsKvb} zi5NT@q$WBz5yZ^}PW5jrwE{6>xCMgB17r^zsgYabN#BpA^E4libfql*w&fed6To2% zjZ~sWZ}}nGC6RHE($0h+*Fu@W!Z@|XLDn~9Ar}Xh(3{m!R(s(0Wl;Ww`62* zK+-w%5VQDJvdnZ+CMXmhP;%Je$!1WdA!I{X6KPhPXcS`JEhy261p1vQV@?8XpXQ2N z+4+nsM^Rp*8jpye1*g^{K#kIT8>SOAtz_%Pl0cDGhE7mPuBs7BYX>x8c)1~A$pSwz zga*Z8Rlq7Ue#;x8X&jx6F;O;IBWMDO6Yk=qITsC7XzW@}Vp2^rtk|+_gS-q2H`p!! zrP*oIE5lfXEjT9;c%U%3f% zbn=SKF$v0CQD-d5LH(Lt=j@69;tP89;>~9CCETqt{{@60@;mYca-~Z2Z$cAhF z9MWF1jj$74PI^b&lpGM!^&2;2h;rku@%w94 z3NU|6#~;r%ZHr<9txXs$b{D@|!htAIC6PpM;{YRyU0Tgin6%=|Y-|A#cfpi0-2G>-X)fKV5F)C?G|gnR21 zL2$V?sE}!@W|(qdUB zfX#nc>h{;-ejPl|LMf_%ySJXN=@%F4;aZ4vNvb6#JS($HPj!_4p;BF(e6|VD#i=GlLpU zJhOU-Lr{Vtc?u9xtB+YaKDSkt8(iH5ic0`TfRk|cwI>ds3MkW5Xo%zZ;R#opm zl`n_^0+}(97<%iWkW_jTIazEQ6D!>&O@!SYHWhK5!WSrk?j9z_BA056Qo>ytmWP&@ zS96R~CQwPWRhX4Jqza+&0|SJ}0yV*`)Udpv$41put5k6!VWo}*T4;2Qx5hK{?nk`a zQ^h;QDs_y)tW+UT!WnT8zy&PxU}YH*=E24$gH=K(1zCw8AcV%gv3!9VuG3^u{e;N) zM7T*d#$djkgmz^xBK>%R9?qdl!;XzkoJD29;&a~QJ8CjtkUof)?ZPxDNHy&Jc6>}i zVv<^~k4GhIsWy(vSY+@;TVMmV)mE9`OknHM@M!v~|Gum9h5{B27{!y|t|7QRjCo@e zeg)5P@X-tjsgh~P_1nzre2B$le9Tb{b?Tp+izo+R+A_CZSO`kuR75mlnLm?C=K(BelhUp|^q9 zmz_ic zjS77Kza>Az!9B6%la?D>oO4_5`oeXlt8?>gm&Y!#%}zA?05aeFd&7Y@9C*WlHyn7w zfj1m@Z5%LuV1&oXg1wrUmsgN!8Mc|B2qKqQu+0<^BiCULyBo>?MA2aW0TUIrs0eki zO>B%^jE>+wcVQ({hs>=sF>!FMb7b5UDR$vLT8G1IszakgDCEXKrxcr4!e%TS)s%;@ z658GfFN0};P$}VTa#SNqPMT;)U{X2DJ}QyoEF_xtkXY$!YJ``#f`PsdaQaXV>97ah zv0!kiSfe)ihG@3KObTge7E4JMsu1i#V{@YetfT-Prqw4ZHvI7NC=I3!tdwM1p@gL@ z3oR5Vp^-O(FOUK;omiOS4B23SM`)Gg!0SgHxd9t$N-&P2q z7GtTN1P~@?LggMjvU1bG0LmJmR7_}uDQ^a~S}O(cYKfBu(0MFtB~r1_*e(Nq7S3pT z77kSclkH)qWDymcqV$;gjX6|~ZWqH1L4N6cL6q;-ENwk#*1U||9-hd@|hN(c!?k|;Wfq{EuI zR<1DZa(5OgQk^kvfUPvI>Tv4h(-wS46&vlRh?D~YyQN3|3`iM_ZMP`A@E?c z=`D6JzbUwG8^eg^5appbz?pEt_f#4MmK=-*U?s^ddi`im!m5U&9dM(hMJzfv?m_+( zwrK9)z;_69Xco<%@AyfRK!>oF+ufElpVho^v;LW}t@#3xh&P9E2@~x(Qe<6+mLs6H z-?9cmgKJbzH$96(Kf=fHh%S*#({gg1$iiUrGBM-kAE}ylq81NIV|z$fp`^YTPOSQq zas#=V*nEydymUAeax5f~la5i^18B%7xwuMYE?`@fkO}5}2JhXDA z#H7-Qu&}2nv3Q9PE&4P&JZouAZo?^P1gJ4m#w(}Gm}Q5_CUdd)e|Tiw)Q^GLW*nm| zF%hvlp%ex5y!Wd0wk?HOUb{?7XSlVRDU};gq%nK>b5Vb@rRx*u^Nqk~4T-Bf^$42Nt@RSv3 z9}}G9G^TCLTnK#)g+M-L2EhSFH5O&Z_a%u_ab}ve{xT^%aUV_^H=J1bO|gJ7ktR^7 zl4BHEIy?Abno%eist0IYW;Ub1G*e*=eH(YkvkAYzVnl%!IPN1x>;#eW4wsQ2@*86z zxQ^wokiI>^&u1I}<9R!V2NpZd4bgN7xgEGYl3Su((Q%&~nyBxv14(2CsoXTBb8u*~E;E`b&W>!*-gc7+9NiqqXPajSN zqu<&JHi1!6s$iA`n?k!(7oS1Hi|?p4AtTFa{usMKA!f#M2#Xit1&}!c8MY7Z25nl2 zUoXUdvK|{JVOnBALn|60(qNu10zj)4qX>IT$TH9p-(X=#-Uc2K4&%*CWhe(RLKAKQ zI2z>RFsQn40=>(rK%7Myqf*#BEm#4b1`G)xJ8($?&}9+iSf*uOi8YAfTw;mlfdJ7l Ku8APc{{KJggGPq{ literal 0 HcmV?d00001 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json new file mode 100644 index 00000000000..1237f9ba1f0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/manifest.json @@ -0,0 +1,291 @@ +{ + "format_version": 1, + "scope": "source-reconstructed-synthetic", + "app_store_provenance": "not-verified", + "wallet_sha": "8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6", + "platform_sha": "fd8d8d13e5d7cea17b00df5974934ab1910e8039", + "source_pair_reference": "https://github.com/dashpay/dashwallet-ios/actions/runs/32706880873", + "fixture_sha256": "1cb3d6c2c299eb9afa9a2152e023acff6c108f1f4caebafc9db8da4a90fbd0da", + "capture": { + "captured_at": "2026-09-20T07:47:06Z", + "xcode_version": "26.6", + "xcode_build": "17F113", + "simulator_runtime": "com.apple.CoreSimulator.SimRuntime.iOS-26-5", + "simulator_device": "iPhone 17", + "architecture": "arm64", + "configuration": "Release", + "sdk_harness_sha": "6faa0545cfeeb279ba5ae3784f848206cfe0f6da", + "source_recipe": "Unversioned Schema(DashSchemaSnapshotV1.models), no migration plan, four synthetic rows", + "byte_reproducibility": "SQLite UUIDs and timestamps vary; compare schema metadata and seeded records, not regenerated file bytes" + }, + "schema": { + "entity_hashes": { + "PersistentAccount": "a5d5dc075d2d634d1a6f0ce025fe0aa323e2747443119c94eb65d024aa47985d", + "PersistentAssetLock": "7fb4ccbc526302d09df6f3f11f9d9eb269c2dd33ee93e1a4a31f72714145399c", + "PersistentCoreAddress": "2518791fe1ebe9fbab8586ba08cb0a527259360e6cfe710287ca73f8959dd14c", + "PersistentDPNSName": "0b3c83f1ed265b4ef9e4ee404d0354f3da7eb70004d38d8bded2f5bae4debaed", + "PersistentDashpayContactProfile": "a5c249ffa9d298c53329262cb85edbb5fc5b77a67f43d7343277ce58d8d4b9d3", + "PersistentDashpayContactRequest": "3ed2ec9984523024a43af961442d85cf02f5db87302e1c8aa68ef9c407d12901", + "PersistentDashpayIgnoredSender": "5386ff4fc63f0b1dfd6a42d6e9b3db3c54339691cb55593ce169c5ba4378f680", + "PersistentDashpayPayment": "5aac0e1c8d892a243dba2967170a0cee067fc56c183b2f4af06c4b66b2839630", + "PersistentDashpayProfile": "e88ec9d87a07af8c2e1faef17b0206a61c9d59322552aab0ffe3133b7059885e", + "PersistentDataContract": "c869c0a2df469be22643847a3e8331e156f364e93aefde2aedd13ef6067bcd66", + "PersistentDocument": "1e143af2e59ef70f72385d661d7ba5d1484fee34f97d9e8d0b2b56fdc879a590", + "PersistentDocumentType": "c3689448841fb9d75e55153213f960504ee839bbc6d695b33b5021d88076c41b", + "PersistentIdentity": "d0e738b1edb8dae40c4b3f916196b6bdcaa9fe499752d211bb69204d80a83418", + "PersistentIndex": "889455232bbb1ac94ab79ccefb6a1ad7de185c6b6e8c9aa7cdc01ed453d659af", + "PersistentInvitation": "4a0a75d2196279e70a911f6d7686f9e29441b582b19ac00b67bc66e88452cf57", + "PersistentKeyword": "286ac09d230fd54050fa33f989e478745766f6e456377137b124d05f2e1a82fb", + "PersistentMasternode": "b33b6a7c27a4df67e0319b96e1f4337e9a67e8213d0d97d540167a140967d656", + "PersistentPendingInput": "483084c729757b6a0acc3118f74773ade4f68b3d6210229229e95a9e49e7ca1e", + "PersistentPlatformAddress": "0cae31a85f8e343c9fe45a8bb44a14958f688456ad6a5c9e39a1331c46e16039", + "PersistentPlatformAddressesSyncState": "de52a03d68033a8cf1bf7e95f2e8f1b8e318ef67cd289ffa9f60595c0a4e24b9", + "PersistentProperty": "c7082d8dcaf538d2eb88916033b8b40f228bd399d758999f2dd0470708fc94b6", + "PersistentPublicKey": "d369346faee596c187c6851eec77d9251414f788f9a11579842cf03492380585", + "PersistentShieldedActivity": "1aef5f0ec88b4be4ccd1a549379c7ca0dfa75b6f48909c22538ba2687d487409", + "PersistentShieldedNote": "d2986310786e015f98f92c5b6cd84db1b1e98eadd623e98d94ebae778cb1e650", + "PersistentShieldedOutgoingNote": "e749ad68716dccc346b798b23c98e21072fd0df19f344bf4b752e15605fe42f1", + "PersistentShieldedSyncState": "2130e7484885f36267753ce4f6d39e216aa6b932e3660234a12b29a136335769", + "PersistentShieldedViewingKey": "bc07713a4b0a63ffdec2e53369e97f089e8de905ab2e8e7e270f71bec7668cdc", + "PersistentToken": "85dacb8b1556f06c9ece6bac94e185e0fdbd8f62554f2b5eca444c121649567c", + "PersistentTokenBalance": "6ea32b253749c13392e4c7aa471b0872d4b1a9d36bb260beb9c6bb4d40a7474e", + "PersistentTokenHistoryEvent": "6e67103e1a6caf7d04f4b5a44d1012a470fef341c36c6cefbfc0e76b440a00ab", + "PersistentTransaction": "daad9b22c2b6c554cc281e7e00eff39ec366373fbaf9553e03ec38ad8f794fbb", + "PersistentTxo": "0a8a83ff1b93058115b34147cc5e797c5691a4975a6892e5d11474ba9d6772b7", + "PersistentWallet": "d2ae76969b1d92b66c6e7df62f7e0ae12e05be2498c9ecf79e3b7d0aa7f96c81", + "PersistentWalletManagerMetadata": "19dbe2cfb7e0c63fa6cd69291e5b1865f58642f43a156db0d9e06028a7bd4d99" + }, + "indexes": [ + "ACHANGE ACHANGE_ZTRANSACTIONID_INDEX: CREATE INDEX ACHANGE_ZTRANSACTIONID_INDEX ON ACHANGE (ZTRANSACTIONID)", + "ATRANSACTION ATRANSACTION_ZAUTHORTS_INDEX: CREATE INDEX ATRANSACTION_ZAUTHORTS_INDEX ON ATRANSACTION (ZAUTHORTS)", + "ATRANSACTION ATRANSACTION_ZBUNDLEIDTS_INDEX: CREATE INDEX ATRANSACTION_ZBUNDLEIDTS_INDEX ON ATRANSACTION (ZBUNDLEIDTS)", + "ATRANSACTION ATRANSACTION_ZCONTEXTNAMETS_INDEX: CREATE INDEX ATRANSACTION_ZCONTEXTNAMETS_INDEX ON ATRANSACTION (ZCONTEXTNAMETS)", + "ATRANSACTION ATRANSACTION_ZPROCESSIDTS_INDEX: CREATE INDEX ATRANSACTION_ZPROCESSIDTS_INDEX ON ATRANSACTION (ZPROCESSIDTS)", + "ATRANSACTION Z_TRANSACTION_TransactionAuthorIndex: CREATE INDEX Z_TRANSACTION_TransactionAuthorIndex ON ATRANSACTION (ZAUTHOR COLLATE BINARY ASC)", + "ATRANSACTION Z_TRANSACTION_TransactionTimestampIndex: CREATE INDEX Z_TRANSACTION_TransactionTimestampIndex ON ATRANSACTION (ZTIMESTAMP COLLATE BINARY ASC)", + "ATRANSACTIONSTRING Z_TRANSACTIONSTRING_UNIQUE_NAME: CREATE UNIQUE INDEX Z_TRANSACTIONSTRING_UNIQUE_NAME ON ATRANSACTIONSTRING (ZNAME COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT ZPERSISTENTACCOUNT_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTACCOUNT_ZWALLET_INDEX ON ZPERSISTENTACCOUNT (ZWALLET)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_accountExtendedPubKeyBytes ON ZPERSISTENTACCOUNT (ZACCOUNTEXTENDEDPUBKEYBYTES COLLATE BINARY ASC)", + "ZPERSISTENTACCOUNT Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId: CREATE UNIQUE INDEX Z_PersistentAccount_UNIQUE_wallet_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId ON ZPERSISTENTACCOUNT (ZWALLET COLLATE BINARY ASC, ZACCOUNTTYPE COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZSTANDARDTAG COLLATE BINARY ASC, ZREGISTRATIONINDEX COLLATE BINARY ASC, ZKEYCLASS COLLATE BINARY ASC, ZUSERIDENTITYID COLLATE BINARY ASC, ZFRIENDIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentAssetLock_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTASSETLOCK (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTASSETLOCK Z_PersistentAssetLock_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentAssetLock_UNIQUE_outPointHex ON ZPERSISTENTASSETLOCK (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTCOREADDRESS ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTCOREADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTCOREADDRESS (ZACCOUNT)", + "ZPERSISTENTCOREADDRESS Z_PersistentCoreAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentCoreAddress_UNIQUE_address ON ZPERSISTENTCOREADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTPROFILE ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTPROFILE_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTPROFILE Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId: CREATE UNIQUE INDEX Z_PersistentDashpayContactProfile_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId ON ZPERSISTENTDASHPAYCONTACTPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYCONTACTREQUEST ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYCONTACTREQUEST_ZOWNER_INDEX ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZOWNER)", + "ZPERSISTENTDASHPAYCONTACTREQUEST Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing: CREATE UNIQUE INDEX Z_PersistentDashpayContactRequest_UNIQUE_networkRaw_ownerIdentityId_contactIdentityId_isOutgoing ON ZPERSISTENTDASHPAYCONTACTREQUEST (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZCONTACTIDENTITYID COLLATE BINARY ASC, ZISOUTGOING COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYIGNOREDSENDER ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYIGNOREDSENDER_ZOWNER_INDEX ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZOWNER)", + "ZPERSISTENTDASHPAYIGNOREDSENDER Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId: CREATE UNIQUE INDEX Z_PersistentDashpayIgnoredSender_UNIQUE_networkRaw_ownerIdentityId_ignoredSenderId ON ZPERSISTENTDASHPAYIGNOREDSENDER (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZIGNOREDSENDERID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPAYMENT ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPAYMENT_ZOWNER_INDEX ON ZPERSISTENTDASHPAYPAYMENT (ZOWNER)", + "ZPERSISTENTDASHPAYPAYMENT Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid: CREATE UNIQUE INDEX Z_PersistentDashpayPayment_UNIQUE_networkRaw_ownerIdentityId_txid ON ZPERSISTENTDASHPAYPAYMENT (ZNETWORKRAW COLLATE BINARY ASC, ZOWNERIDENTITYID COLLATE BINARY ASC, ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTDASHPAYPROFILE ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDASHPAYPROFILE_ZIDENTITY_INDEX ON ZPERSISTENTDASHPAYPROFILE (ZIDENTITY)", + "ZPERSISTENTDASHPAYPROFILE Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity: CREATE UNIQUE INDEX Z_PersistentDashpayProfile_UNIQUE_networkRaw_identity ON ZPERSISTENTDASHPAYPROFILE (ZNETWORKRAW COLLATE BINARY ASC, ZIDENTITY COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDATACONTRACT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDATACONTRACT (ZOWNERIDENTITY)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDataContract_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDATACONTRACT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDATACONTRACT Z_PersistentDataContract_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDataContract_UNIQUE_id ON ZPERSISTENTDATACONTRACT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENT (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZDOCUMENTTYPE_RELATION_INDEX ON ZPERSISTENTDOCUMENT (ZDOCUMENTTYPE_RELATION)", + "ZPERSISTENTDOCUMENT ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDOCUMENT_ZOWNERIDENTITY_INDEX ON ZPERSISTENTDOCUMENT (ZOWNERIDENTITY)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentDocument_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTDOCUMENT (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENT Z_PersistentDocument_UNIQUE_documentId: CREATE UNIQUE INDEX Z_PersistentDocument_UNIQUE_documentId ON ZPERSISTENTDOCUMENT (ZDOCUMENTID COLLATE BINARY ASC)", + "ZPERSISTENTDOCUMENTTYPE ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTDOCUMENTTYPE_ZDATACONTRACT_INDEX ON ZPERSISTENTDOCUMENTTYPE (ZDATACONTRACT)", + "ZPERSISTENTDOCUMENTTYPE Z_PersistentDocumentType_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentDocumentType_UNIQUE_id ON ZPERSISTENTDOCUMENTTYPE (ZID COLLATE BINARY ASC)", + "ZPERSISTENTDPNSNAME ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTDPNSNAME_ZIDENTITY_INDEX ON ZPERSISTENTDPNSNAME (ZIDENTITY)", + "ZPERSISTENTDPNSNAME Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel: CREATE UNIQUE INDEX Z_PersistentDPNSName_UNIQUE_networkRaw_normalizedParentDomainName_normalizedLabel ON ZPERSISTENTDPNSNAME (ZNETWORKRAW COLLATE BINARY ASC, ZNORMALIZEDPARENTDOMAINNAME COLLATE BINARY ASC, ZNORMALIZEDLABEL COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZDASHPAYPROFILE_INDEX ON ZPERSISTENTIDENTITY (ZDASHPAYPROFILE)", + "ZPERSISTENTIDENTITY ZPERSISTENTIDENTITY_ZWALLET_INDEX: CREATE INDEX ZPERSISTENTIDENTITY_ZWALLET_INDEX ON ZPERSISTENTIDENTITY (ZWALLET)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentIdentity_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTIDENTITY (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTIDENTITY Z_PersistentIdentity_UNIQUE_identityId: CREATE UNIQUE INDEX Z_PersistentIdentity_UNIQUE_identityId ON ZPERSISTENTIDENTITY (ZIDENTITYID COLLATE BINARY ASC)", + "ZPERSISTENTINDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTINDEX_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTINDEX (ZDOCUMENTTYPE)", + "ZPERSISTENTINDEX Z_PersistentIndex_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentIndex_UNIQUE_id ON ZPERSISTENTINDEX (ZID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentInvitation_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTINVITATION (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTINVITATION Z_PersistentInvitation_UNIQUE_outPointHex: CREATE UNIQUE INDEX Z_PersistentInvitation_UNIQUE_outPointHex ON ZPERSISTENTINVITATION (ZOUTPOINTHEX COLLATE BINARY ASC)", + "ZPERSISTENTKEYWORD ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTKEYWORD_ZDATACONTRACT_INDEX ON ZPERSISTENTKEYWORD (ZDATACONTRACT)", + "ZPERSISTENTKEYWORD Z_PersistentKeyword_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentKeyword_UNIQUE_id ON ZPERSISTENTKEYWORD (ZID COLLATE BINARY ASC)", + "ZPERSISTENTMASTERNODE Z_PersistentMasternode_UNIQUE_walletId_proTxHash: CREATE UNIQUE INDEX Z_PersistentMasternode_UNIQUE_walletId_proTxHash ON ZPERSISTENTMASTERNODE (ZWALLETID COLLATE BINARY ASC, ZPROTXHASH COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTPENDINGINPUT_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTPENDINGINPUT (ZSPENDINGTRANSACTION)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinaryoutpoint ON ZPERSISTENTPENDINGINPUT (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTPENDINGINPUT Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPendingInput_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPENDINGINPUT (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTPLATFORMADDRESS_ZACCOUNT_INDEX ON ZPERSISTENTPLATFORMADDRESS (ZACCOUNT)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentPlatformAddress_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTPLATFORMADDRESS (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_address: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_address ON ZPERSISTENTPLATFORMADDRESS (ZADDRESS COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESS Z_PersistentPlatformAddress_UNIQUE_addressHash: CREATE UNIQUE INDEX Z_PersistentPlatformAddress_UNIQUE_addressHash ON ZPERSISTENTPLATFORMADDRESS (ZADDRESSHASH COLLATE BINARY ASC)", + "ZPERSISTENTPLATFORMADDRESSESSYNCSTATE Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentPlatformAddressesSyncState_UNIQUE_walletId ON ZPERSISTENTPLATFORMADDRESSESSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTPROPERTY ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX: CREATE INDEX ZPERSISTENTPROPERTY_ZDOCUMENTTYPE_INDEX ON ZPERSISTENTPROPERTY (ZDOCUMENTTYPE)", + "ZPERSISTENTPROPERTY Z_PersistentProperty_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentProperty_UNIQUE_id ON ZPERSISTENTPROPERTY (ZID COLLATE BINARY ASC)", + "ZPERSISTENTPUBLICKEY ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTPUBLICKEY_ZIDENTITY_INDEX ON ZPERSISTENTPUBLICKEY (ZIDENTITY)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedActivity_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDACTIVITY Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId: CREATE UNIQUE INDEX Z_PersistentShieldedActivity_UNIQUE_walletId_accountIndex_entryId ON ZPERSISTENTSHIELDEDACTIVITY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZENTRYID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDNOTE Z_PersistentShieldedNote_UNIQUE_nullifier: CREATE UNIQUE INDEX Z_PersistentShieldedNote_UNIQUE_nullifier ON ZPERSISTENTSHIELDEDNOTE (ZNULLIFIER COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex: CREATE INDEX Z_PersistentShieldedOutgoingNote_SwiftDataIndexOnBinarywalletIdaccountIndex ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDOUTGOINGNOTE Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx: CREATE UNIQUE INDEX Z_PersistentShieldedOutgoingNote_UNIQUE_walletId_accountIndex_cmx ON ZPERSISTENTSHIELDEDOUTGOINGNOTE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC, ZCMX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedSyncState_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDSYNCSTATE Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedSyncState_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDSYNCSTATE (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentShieldedViewingKey_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTSHIELDEDVIEWINGKEY Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex: CREATE UNIQUE INDEX Z_PersistentShieldedViewingKey_UNIQUE_walletId_accountIndex ON ZPERSISTENTSHIELDEDVIEWINGKEY (ZWALLETID COLLATE BINARY ASC, ZACCOUNTINDEX COLLATE BINARY ASC)", + "ZPERSISTENTTOKEN ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX: CREATE INDEX ZPERSISTENTTOKEN_ZDATACONTRACT_INDEX ON ZPERSISTENTTOKEN (ZDATACONTRACT)", + "ZPERSISTENTTOKEN Z_PersistentToken_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentToken_UNIQUE_id ON ZPERSISTENTTOKEN (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_Z13TOKENBALANCES_INDEX ON ZPERSISTENTTOKENBALANCE (Z13TOKENBALANCES)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZIDENTITY_INDEX ON ZPERSISTENTTOKENBALANCE (ZIDENTITY)", + "ZPERSISTENTTOKENBALANCE ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENBALANCE_ZTOKEN_INDEX ON ZPERSISTENTTOKENBALANCE (ZTOKEN)", + "ZPERSISTENTTOKENBALANCE Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentTokenBalance_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTTOKENBALANCE (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTTOKENHISTORYEVENT ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX: CREATE INDEX ZPERSISTENTTOKENHISTORYEVENT_ZTOKEN_INDEX ON ZPERSISTENTTOKENHISTORYEVENT (ZTOKEN)", + "ZPERSISTENTTOKENHISTORYEVENT Z_PersistentTokenHistoryEvent_UNIQUE_id: CREATE UNIQUE INDEX Z_PersistentTokenHistoryEvent_UNIQUE_id ON ZPERSISTENTTOKENHISTORYEVENT (ZID COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen: CREATE INDEX Z_PersistentTransaction_SwiftDataIndexOnBinaryfirstSeen ON ZPERSISTENTTRANSACTION (ZFIRSTSEEN COLLATE BINARY ASC)", + "ZPERSISTENTTRANSACTION Z_PersistentTransaction_UNIQUE_txid: CREATE UNIQUE INDEX Z_PersistentTransaction_UNIQUE_txid ON ZPERSISTENTTRANSACTION (ZTXID COLLATE BINARY ASC)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZACCOUNT_INDEX: CREATE INDEX ZPERSISTENTTXO_ZACCOUNT_INDEX ON ZPERSISTENTTXO (ZACCOUNT)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZCOREADDRESS_INDEX: CREATE INDEX ZPERSISTENTTXO_ZCOREADDRESS_INDEX ON ZPERSISTENTTXO (ZCOREADDRESS)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZSPENDINGTRANSACTION_INDEX ON ZPERSISTENTTXO (ZSPENDINGTRANSACTION)", + "ZPERSISTENTTXO ZPERSISTENTTXO_ZTRANSACTION_INDEX: CREATE INDEX ZPERSISTENTTXO_ZTRANSACTION_INDEX ON ZPERSISTENTTXO (ZTRANSACTION)", + "ZPERSISTENTTXO Z_PersistentTxo_SwiftDataIndexOnBinarywalletId: CREATE INDEX Z_PersistentTxo_SwiftDataIndexOnBinarywalletId ON ZPERSISTENTTXO (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTTXO Z_PersistentTxo_UNIQUE_outpoint: CREATE UNIQUE INDEX Z_PersistentTxo_UNIQUE_outpoint ON ZPERSISTENTTXO (ZOUTPOINT COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarynetworkRaw ON ZPERSISTENTWALLET (ZNETWORKRAW COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId: CREATE INDEX Z_PersistentWallet_SwiftDataIndexOnBinarywalletGroupId ON ZPERSISTENTWALLET (ZWALLETGROUPID COLLATE BINARY ASC)", + "ZPERSISTENTWALLET Z_PersistentWallet_UNIQUE_walletId: CREATE UNIQUE INDEX Z_PersistentWallet_UNIQUE_walletId ON ZPERSISTENTWALLET (ZWALLETID COLLATE BINARY ASC)", + "ZPERSISTENTWALLETMANAGERMETADATA Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw: CREATE UNIQUE INDEX Z_PersistentWalletManagerMetadata_UNIQUE_networkRaw ON ZPERSISTENTWALLETMANAGERMETADATA (ZNETWORKRAW COLLATE BINARY ASC)", + "Z_1INVOLVEDTRANSACTIONS Z_1INVOLVEDTRANSACTIONS_Z_31INVOLVEDTRANSACTIONS_INDEX: CREATE INDEX Z_1INVOLVEDTRANSACTIONS_Z_31INVOLVEDTRANSACTIONS_INDEX ON Z_1INVOLVEDTRANSACTIONS (Z_31INVOLVEDTRANSACTIONS, Z_1INVOLVEDACCOUNTS)", + "Z_1INVOLVEDTRANSACTIONS sqlite_autoindex_Z_1INVOLVEDTRANSACTIONS_1: (auto)" + ], + "model_checksum": "wOm/tD2jkxoKsyP7GFXVNeebjqpLZbKZZ4EqYLkwlMk=", + "schema_version": "1.0.0" + }, + "inventory": { + "format_version": 1, + "models": { + "PersistentIdentity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift", + "PersistentDPNSName": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift", + "PersistentDashpayProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift", + "PersistentDashpayContactProfile": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift", + "PersistentDashpayContactRequest": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift", + "PersistentDashpayPayment": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift", + "PersistentDashpayIgnoredSender": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift", + "PersistentDocument": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift", + "PersistentDataContract": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift", + "PersistentPublicKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift", + "PersistentTokenBalance": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift", + "PersistentKeyword": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentKeyword.swift", + "PersistentToken": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift", + "PersistentDocumentType": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift", + "PersistentIndex": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIndex.swift", + "PersistentProperty": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentProperty.swift", + "PersistentTokenHistoryEvent": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenHistoryEvent.swift", + "PersistentPlatformAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift", + "PersistentPlatformAddressesSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift", + "PersistentWallet": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift", + "PersistentAccount": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift", + "PersistentCoreAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift", + "PersistentTransaction": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift", + "PersistentTxo": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift", + "PersistentPendingInput": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift", + "PersistentWalletManagerMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWalletManagerMetadata.swift", + "PersistentShieldedNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift", + "PersistentShieldedOutgoingNote": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedOutgoingNote.swift", + "PersistentShieldedSyncState": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift", + "PersistentShieldedActivity": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift", + "PersistentShieldedViewingKey": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedViewingKey.swift", + "PersistentAssetLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift", + "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", + "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift" + }, + "value_types": [ + { + "path": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift", + "names": [ + "ChangeControlRules", + "AuthorizedActionTakers", + "TokenPerpetualDistribution", + "TokenPreProgrammedDistribution", + "DistributionEvent", + "TokenDistributionChangeRules", + "TokenTradeMode", + "TokenLocalization" + ] + } + ] + }, + "synthetic_record_counts": { + "ZPERSISTENTWALLET": 1, + "ZPERSISTENTDATACONTRACT": 1, + "ZPERSISTENTDOCUMENTTYPE": 1, + "ZPERSISTENTINDEX": 1 + }, + "capture_test_sha256": "84631bea640a9d88e35bd90d499a727cc929d8c5fc68154e8c85baf4d5b271a4", + "source_file_sha256": { + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift": "18d73c03dd50b71a4840301eb4cecf55d7c66aaeec2cd91471d256b6f416669d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAccount.swift": "d41e5c30fa0153f558965fc029541efbca6e11aab91edb8f54247957b97b499b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentAssetLock.swift": "d7be59c9732e2a9323343b2e9dd4c87c6fa54fe3b117576f46c4ae882b96d30b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreAddress.swift": "83004a3791c8ddc2a47c079fe92b76ad322e0756f1d94b806822c3644986008b", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDPNSName.swift": "78b448497bfab1a7afd14373be528d2e6d64417d0342dbe6b8e4ff93dde022dc", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactProfile.swift": "ff1ca4b467acc4226a939d1c2280780e43f14f9f1efff079ba8f31445420be4f", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayContactRequest.swift": "3989bd07e064cad9bf6cefcfdf9bfa297f2d1b4ed0d568cb3c7696b27d593e56", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayIgnoredSender.swift": "ff95f860223b4e6a9cf25c47aaa77d8a50ae64a2536e0043a984d4838b6b5bc9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayPayment.swift": "942342d9ae8dc1a7fc249028a0e8cb7efd37866db74ceb5d0a72090ef12241ce", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDashpayProfile.swift": "ab1a8940629b6c24f65e0883a9162abc853d78959ff2bcfc34af193b7b4177ca", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDataContract.swift": "3b978e0e68079f8c43be75e30fc3e9a89919825219d0dfc4086e6b2aa7bba371", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocument.swift": "cf0d597ef74f657bec9f1d29177865c580f30d5fe597793b696ce26a48afe2cf", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentDocumentType.swift": "921452a774c00c01e3edcc3287a7c306666e8a0633bb77c413bb0c12fc6b7133", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift": "45717fbaf11052656d67121308952c729ea3f0026b9db9c876650987abcb6186", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIndex.swift": "b413448aae646fa910165bec2f5cb3d9d757ecd54b442caebceec4bccc161245", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift": "2c7bfcb10891bf4e18426899239e05ec2497e22b480fc7095bd02f631b687b70", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentKeyword.swift": "955911db8593628f3f21a72ea2c1919ed67b23a1e656562b210a1699c6bf5e64", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift": "74d07907b89b7fcc107b9096c32487d09dfb8840b121286b27639e84cd66abc9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPendingInput.swift": "99164c7a878ff66b4e7b77437da370a36aae277d0013ab58b9bb025f0ebaa0ba", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddress.swift": "ccc8b1b748ab9fce12c4bba0c9d51ee4cce1c165f840146d0e4548519a4414a8", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift": "d110299d6f7eaaeadadab6ed7ef9a974a096d907a754e17856b12d9c6df40a37", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentProperty.swift": "971f0590cf5997656dd485c4b348aabad3d7ee6aab3f18422bbfd9694b272c15", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift": "07fbbd0a2f0022c40b6f5bc6a9684c50d6041877e4a86f9060e3d320c14f335a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedActivity.swift": "87a1edf2f7f0e2ed7ba8ad6e8edd6b2f6971f83396854123fcdb1857559e2ed4", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedNote.swift": "26d386a26eb57505dab1d6bdbaf1d383e3af8b72e386631d87c7940db09f2cd9", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedOutgoingNote.swift": "209b7863cbb46cefe0b22ff46632f24cc8d182cc9209c1bfc305b37fb99252eb", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedSyncState.swift": "0c5a752cac01a40f2283ba5e0411e88296ec7256357f4f7c37a4e212c41efed5", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentShieldedViewingKey.swift": "0ba3e4ca1ee691c528c65c8708858c4b71c569d4ef53f7fe98ea6bd8bcdafa77", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift": "0fc7393eac877e6a771d1aa8bfbbde98b4d7b4b06aa98853574508a3bd9e94f2", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenBalance.swift": "fdc0d15c10cb197b19aa416c33b27fc1423acb722dd21fc4440e611448882ee7", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTokenHistoryEvent.swift": "e0ea0a67954757944b557511adcac1ca21155128a27731ef251c3ea101214b34", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift": "2c546218c12a2c003aa62a7b16f077e517d67d320f035a18caf6b0d203b3138d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTxo.swift": "992249f8379300e43de74ce0bf4efcc05b186b1c9981068213e47d99aed24702", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift": "a84b5b7d85fdfbdb2b4cd6e191a148b7c8120031709de82b84a3eb4a641a1e08", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWalletManagerMetadata.swift": "50f2aa9a5c045e95e765181765941c7bda0f1830c447e79fd612c99300f5a39a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift": "d03b8b0454739cceba6c09a4e1fe197fc462aacb621948ce4bf0939f7f38c9d0" + }, + "generated_file_sha256": { + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentAccount.swift": "865cf76f699756dacbaf0b82d912754aa50c5917a6e1052bdaa69b2ef9b74ec1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentAssetLock.swift": "416bbe0d1c65bfbecdc52008a0f80ebb0e6b3d67526bba8171812de5e3ad2eaa", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentCoreAddress.swift": "eb4ce2e66b3bcb67b73ad4e92d290a85cb7310de09616f0bfe2df147e69e1684", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDPNSName.swift": "23f4c4dc81a824606a97a433e4385352958385402435fb1ed2fe68bbd82da888", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayContactProfile.swift": "4834a1df5d7143bdf8d159a18ff66f87085e44979187e3ac836d614bc5d4cffb", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayContactRequest.swift": "cc3b07c012b1a7a76d18197211f3f5e0ca0c80630398164c892dcc293091c5ae", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayIgnoredSender.swift": "74954331970a5121161514e1a5cca2a2c2a0c9203aa877643be660f575a79270", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayPayment.swift": "c4e680f8c4dfdac4de9805bc3f9f270d3df160f56b35439e52c154d1d9a004ed", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDashpayProfile.swift": "79d57bec33e6e451fb21ff9bd94b9298272f4673ea09c430d658c5659d2f74e0", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDataContract.swift": "43b9a7273112cc78856160f1ea123787f5c03339fa80c9fdfa52ace09fc2092c", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDocument.swift": "65a596be6f34c330b659a8728aa3dead63971e479d9c46ea1180f76add1c4552", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentDocumentType.swift": "d1ad81d8c53496363e290c40e242c4696db1308202bb477b578423152dc5edf4", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentIdentity.swift": "8e7bfc91d1938645762382f23e95c84fb8307eaa69faea7ba46acd18d30ea350", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentIndex.swift": "787f6f67590a17c47e59f610644c8dac1c224ea0bb799fba4e6bf82618c87f2a", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentInvitation.swift": "1bf9cecfb38e7dc9cad7d801b0d8b9ed66af047796713498915bcc244805fde5", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentKeyword.swift": "ac87c6205cd9874c268fa6bcafbdf1360a2da881de06905799a1f98184c5c676", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentMasternode.swift": "d720b933fa75cbf15784d48682361607553d68377e119c79236a289df047353d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPendingInput.swift": "77066c3cefc7b013c6dbe9ddaf0cdae9b963cdd952a0036ac205753b0ba80aec", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPlatformAddress.swift": "af395900368c6e4c173c1728b54c0db503dab0df5a50ac88623dfa03e1fecf06", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPlatformAddressesSyncState.swift": "639bf3f74ffb57a0fa1f327a5172b329292b65e02db6bd1537f32af0dc90f7a1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentProperty.swift": "6b18f01fa5c8224ab5236a08ff969a3dce76ed4cde691eaa10022fdf66d5ebf1", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentPublicKey.swift": "32b690d6fb38bdb8bc5da3591599f72ad8699c01dd4fadd649a69b8d831dacea", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedActivity.swift": "59c5ee79088df4d28a8c1948e1a1b18d9ef818096e1891e46b3708eae0fdb0cf", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedNote.swift": "c0bda341fa61204d8b246fdd567d7c19429e60607b785de2aeee4f891f2a852d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedOutgoingNote.swift": "0a1e69420ad3f67addc4c085b1671760699e743239f86ac367b8d67e9168a672", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedSyncState.swift": "46da086c3602d3f1dc55752e52dc671524455f55d3a5825e59924734b485d241", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentShieldedViewingKey.swift": "089c19f1185748434e45bdd7a6604c22839762a1ae1d8c9302f26c46ee5fa2ef", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentToken.swift": "fa5c3d0cd447b24ad380e788a26b1b531f8fa4a9921ed05fb497bd4c74953538", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTokenBalance.swift": "f36f5d2bdb80731ccd23746ebe1a0a35ced50f6b7408b62e03144f455ee4d94d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTokenHistoryEvent.swift": "cea23ca88e8b77d1ce880c93ddb23b9c32b14eb8c87a2bbed5d689dd5439d011", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTransaction.swift": "c0144541bcfc60ef5fb03fb27fdcf5b1bb032c050f7dcbb4e7690b9d4fb15f93", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentTxo.swift": "d20ef71b56be0a36ecfd74d9a535aa475ed003b2938efe72a56c55d2e1e0793d", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentWallet.swift": "333bbbc5356f229a9be73e9f12c037ea3bcf2b1522c9041ddf4c992682e3e308", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+PersistentWalletManagerMetadata.swift": "8ca2fb8d019235340fcca7aa2a2813cec206be1b9b7273583a7f3a357b387e92", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+Schema.swift": "cd99a70478c35346cdc069ff10a61b656b49fd4086479bbebcbbe4ecb7556a74", + "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV1+TokenTypes.swift": "3d6c8bf8af4f714951917b64b51bb056f43667cf78c3e3d0549425c7c3abd858" + } +} diff --git a/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift b/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift new file mode 100644 index 00000000000..f0a495dec56 --- /dev/null +++ b/packages/swift-sdk/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift @@ -0,0 +1,79 @@ +// Reproduction recipe, copied into a disposable SDK by historical_schema_fixture.py. +// This file and the historical model graph are not compiled into the shipping SDK. +import Foundation +import SQLite3 +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +@MainActor +final class DashHistoricalFixtureCaptureTests: XCTestCase { + func testCaptureHistoricalSourceFixture() throws { + // Match the original audit's model-registration order. + _ = DashModelContainer.schema + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = directory.appendingPathComponent("historical.store") + try autoreleasepool { + // The historical iOS app used an unversioned Schema with no migration plan. + let schema = Schema(DashSchemaSnapshotV1.models) + let configuration = ModelConfiguration( + schema: schema, url: store, allowsSave: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let wallet = DashSchemaSnapshotV1.PersistentWallet( + walletId: Data(repeating: 0x61, count: 32), network: .testnet, + name: "historical audit wallet") + let contractId = Data(repeating: 0x62, count: 32) + let contract = DashSchemaSnapshotV1.PersistentDataContract( + id: contractId, name: "historical audit contract", + serializedContract: Data("{}".utf8), network: .testnet) + let type = DashSchemaSnapshotV1.PersistentDocumentType( + contractId: contractId, name: "audit", + schemaJSON: Data("{}".utf8), propertiesJSON: Data("{}".utf8)) + let index = DashSchemaSnapshotV1.PersistentIndex( + contractId: contractId, documentTypeName: "audit", name: "byName", properties: ["name"]) + container.mainContext.insert(wallet) + container.mainContext.insert(contract) + container.mainContext.insert(type) + container.mainContext.insert(index) + type.dataContract = contract + index.documentType = type + try container.mainContext.save() + } + try checkpoint(store) + let captured = try DashSchemaFixtureSupport.describeStore(at: store, version: Schema.Version(1, 0, 0)) + let provenanceURL = try XCTUnwrap(Bundle.module.url( + forResource: "manifest", withExtension: "json", + subdirectory: "Fixtures/SchemaStores/legacy-fd8d8d13e5")) + struct Provenance: Decodable { let schema: DashSchemaFixtureSupport.Description } + let provenance = try JSONDecoder().decode(Provenance.self, from: Data(contentsOf: provenanceURL)) + XCTAssertEqual(captured, provenance.schema, "Recreated model hashes and indexes must match the committed fixture") + let attachment = XCTAttachment(contentsOfFile: store) + attachment.name = "legacy-fd8d8d13e5.store" + attachment.lifetime = .keepAlways + add(attachment) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let metadata = XCTAttachment(data: try encoder.encode(captured), uniformTypeIdentifier: "public.json") + metadata.name = "legacy-fd8d8d13e5.schema.json" + metadata.lifetime = .keepAlways + add(metadata) + } + + private func checkpoint(_ url: URL) throws { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE, nil) == SQLITE_OK else { + sqlite3_close(database) + throw NSError(domain: "HistoricalFixtureSQLite", code: 1) + } + defer { sqlite3_close(database) } + guard sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK, + sqlite3_exec(database, "PRAGMA journal_mode=DELETE", nil, nil, nil) == SQLITE_OK, + !FileManager.default.fileExists(atPath: url.path + "-wal"), + !FileManager.default.fileExists(atPath: url.path + "-shm") else { + throw NSError(domain: "HistoricalFixtureSQLite", code: 2) + } + } +} diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index 2685f4a2ff9..02a01b7c657 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -363,6 +363,10 @@ def validate_schema(schema): def read_inventory(root, commit): inventory = json.loads(git(root, "show", f"{commit}:{INVENTORY_FILE}")) + return validate_inventory(inventory) + + +def validate_inventory(inventory): if inventory.get("format_version") != 1: raise SystemExit("unsupported historical schema model inventory") models = inventory["models"] @@ -376,7 +380,7 @@ def read_inventory(root, commit): return inventory -def render_snapshot(root, version, entry): +def render_snapshot(root, version, entry, *, inventory=None): validate_schema(entry["schema"]) if entry["schema"]["schema_version"] != version: raise SystemExit("registry key does not match captured schema version") @@ -386,7 +390,7 @@ def render_snapshot(root, version, entry): namespace = "DashSchemaSnapshotV" + version.split(".")[0] if entry["namespace"] != namespace: raise SystemExit("unexpected snapshot namespace") - inventory = read_inventory(root, commit) + inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) models = inventory["models"] if set(models) != set(entry["schema"]["entity_hashes"]): raise SystemExit("historical inventory differs from captured model membership") diff --git a/packages/swift-sdk/scripts/historical_schema_fixture.py b/packages/swift-sdk/scripts/historical_schema_fixture.py new file mode 100644 index 00000000000..5f81651ac12 --- /dev/null +++ b/packages/swift-sdk/scripts/historical_schema_fixture.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Verify and reproduce the source-reconstructed fd8 legacy SwiftData fixture. + +The store is synthetic evidence for a pinned iOS/Platform source pair. It is not +an extracted App Store database and does not change the accepted frozen V1. +--check validates the committed artifact and deterministic historical model +rendering. --prepare-sdk exports those models and a dedicated capture test into +a disposable copy of the SDK, never into the production source tree. +""" + +import argparse +import contextlib +import hashlib +import json +from pathlib import Path +import re +import shutil +import sqlite3 + +import freeze_schema_models as freeze + + +SDK = "packages/swift-sdk" +FIXTURE_DIR = f"{SDK}/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5" +MANIFEST = f"{FIXTURE_DIR}/manifest.json" +CAPTURE_TEST = f"{SDK}/scripts/fixtures/DashHistoricalFixtureCaptureTests.swift" +PLATFORM_SHA = "fd8d8d13e5d7cea17b00df5974934ab1910e8039" +IOS_SHA = "8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6" +CONTAINER = f"{SDK}/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift" + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def read_manifest(root): + manifest = json.loads(Path(root, MANIFEST).read_text()) + if (manifest.get("format_version") != 1 + or manifest.get("scope") != "source-reconstructed-synthetic" + or manifest.get("app_store_provenance") != "not-verified" + or manifest.get("platform_sha") != PLATFORM_SHA + or manifest.get("wallet_sha") != IOS_SHA): + raise SystemExit("historical fixture provenance changed") + return manifest + + +def render_graph(root, manifest): + inventory = freeze.validate_inventory(manifest["inventory"]) + # This particular historical factory has a literal modelTypes list. Compare + # the explicit inventory with that list; do not infer general Swift types. + factory = freeze.git(root, "show", f"{PLATFORM_SHA}:{CONTAINER}") + declaration = re.search( + r"public static var modelTypes:\s*\[any PersistentModel.Type\]\s*\{\s*\[(.*?)\]\s*\}", + factory, re.S) + if declaration is None: + raise SystemExit("historical modelTypes declaration is missing") + models = re.findall(r"\b(Persistent\w+)\.self\b", declaration.group(1)) + if models != list(inventory["models"]): + raise SystemExit("historical inventory differs from its pinned factory") + return freeze.render_snapshot(root, "1.0.0", { + "platform_sha": PLATFORM_SHA, + "namespace": "DashSchemaSnapshotV1", + "schema": manifest["schema"], + }, inventory=inventory) + + +def verify(root, manifest=None): + root = Path(root) + manifest = read_manifest(root) if manifest is None else manifest + freeze.validate_schema(manifest["schema"]) + fixture = root / FIXTURE_DIR / "fixture.store" + if digest(fixture.read_bytes()) != manifest["fixture_sha256"]: + raise SystemExit("historical fixture checksum mismatch") + freeze.validate_fixture_description(fixture, manifest["schema"]) + uri = fixture.resolve().as_uri() + "?mode=ro&immutable=1" + with contextlib.closing(sqlite3.connect(uri, uri=True)) as database: + if database.execute("PRAGMA quick_check").fetchone() != ("ok",): + raise SystemExit("historical fixture SQLite integrity check failed") + counts = {table: database.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0] + for table in ("ZPERSISTENTWALLET", "ZPERSISTENTDATACONTRACT", + "ZPERSISTENTDOCUMENTTYPE", "ZPERSISTENTINDEX")} + if counts != manifest["synthetic_record_counts"]: + raise SystemExit("historical synthetic records changed") + files = set(manifest["inventory"]["models"].values()) | { + item["path"] for item in manifest["inventory"]["value_types"] + } | {CONTAINER} + sources = {path: digest(freeze.git(root, "show", f"{PLATFORM_SHA}:{path}").encode()) + for path in sorted(files)} + if sources != manifest["source_file_sha256"]: + raise SystemExit("historical source file digests differ from the manifest") + graph = render_graph(root, manifest) + if {path: digest(text.encode()) for path, text in sorted(graph.items())} != manifest["generated_file_sha256"]: + raise SystemExit("historical graph regeneration differs from the recorded generator output") + if digest((root / CAPTURE_TEST).read_bytes()) != manifest["capture_test_sha256"]: + raise SystemExit("historical capture recipe changed") + return graph + + +def prepare_sdk(root, destination): + root, destination = Path(root).resolve(), Path(destination).resolve() + if destination.exists() or destination.is_relative_to(root): + raise SystemExit("capture SDK must be a new directory outside the repository") + graph = verify(root) + sdk = root / SDK + framework = sdk / "DashSDKFFI.xcframework" + if not framework.exists(): + raise SystemExit("build the current SDK simulator XCFramework before preparing the capture SDK") + destination.mkdir(parents=True) + for folder in ("Sources", "SwiftTests"): + shutil.copytree(sdk / folder, destination / folder) + shutil.copy2(sdk / "Package.swift", destination / "Package.swift") + (destination / "DashSDKFFI.xcframework").symlink_to(framework.resolve(), target_is_directory=True) + for path, text in graph.items(): + output = destination / Path(path).relative_to(SDK) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text) + shutil.copy2(root / CAPTURE_TEST, destination / "SwiftTests/SwiftDashSDKTests/DashHistoricalFixtureCaptureTests.swift") + print(f"Prepared disposable SDK at {destination}") + print("Run only DashHistoricalFixtureCaptureTests on an arm64 iOS simulator, Release configuration.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", type=Path, default=Path.cwd()) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--prepare-sdk", type=Path, metavar="NEW_DIRECTORY") + args = parser.parse_args() + root = Path(freeze.repo_root(args.repo)) + if args.check: + graph = verify(root) + print(f"Historical fixture and {len(graph)} generated source files match the pinned source pair") + else: + prepare_sdk(root, args.prepare_sdk) + + +if __name__ == "__main__": + main() diff --git a/packages/swift-sdk/scripts/test_historical_schema_fixture.py b/packages/swift-sdk/scripts/test_historical_schema_fixture.py new file mode 100644 index 00000000000..566407e7d7e --- /dev/null +++ b/packages/swift-sdk/scripts/test_historical_schema_fixture.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Regression checks for the pinned, source-reconstructed legacy fixture.""" + +import copy +import json +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +import historical_schema_fixture as historical + + +ROOT = Path(historical.freeze.repo_root()) + + +class HistoricalFixtureTests(unittest.TestCase): + def setUp(self): + self.manifest = historical.read_manifest(ROOT) + + def test_should_reproduce_the_committed_artifacts_from_the_pinned_source_pair(self): + graph = historical.verify(ROOT) + self.assertEqual(len(graph), 36) + self.assertEqual(len(self.manifest["schema"]["entity_hashes"]), 34) + self.assertNotIn("PersistentTrackedMasternode", self.manifest["inventory"]["models"]) + + def test_should_reject_provenance_that_claims_a_different_source_or_app_store_release(self): + with tempfile.TemporaryDirectory() as scratch: + path = Path(scratch, historical.MANIFEST) + path.parent.mkdir(parents=True) + for key, value in (("platform_sha", "a" * 40), ("wallet_sha", "b" * 40), + ("app_store_provenance", "verified")): + path.write_text(json.dumps(dict(self.manifest, **{key: value}))) + with self.subTest(key=key), self.assertRaisesRegex(SystemExit, "provenance changed"): + historical.read_manifest(scratch) + + def test_should_reject_changed_fixture_bytes_before_reading_history(self): + with tempfile.TemporaryDirectory() as scratch: + fixture = Path(scratch, historical.FIXTURE_DIR, "fixture.store") + fixture.parent.mkdir(parents=True) + fixture.write_bytes(b"not the recorded fixture") + with mock.patch.object(historical.freeze, "git") as git: + with self.assertRaisesRegex(SystemExit, "checksum mismatch"): + historical.verify(scratch, self.manifest) + git.assert_not_called() + + def test_should_reject_a_schema_description_that_does_not_match_the_sqlite_store(self): + manifest = copy.deepcopy(self.manifest) + manifest["schema"]["entity_hashes"]["PersistentDocumentType"] = "00" * 32 + with self.assertRaisesRegex(SystemExit, "SQLite fixture metadata"): + historical.verify(ROOT, manifest) + + def test_should_reject_changes_to_the_recorded_synthetic_rows(self): + self.manifest["synthetic_record_counts"]["ZPERSISTENTWALLET"] = 2 + with self.assertRaisesRegex(SystemExit, "synthetic records changed"): + historical.verify(ROOT, self.manifest) + + def test_should_reject_an_inventory_that_differs_from_the_historical_factory(self): + self.manifest["inventory"]["models"].pop("PersistentWallet") + with self.assertRaisesRegex(SystemExit, "pinned factory"): + historical.render_graph(ROOT, self.manifest) + + def test_should_detect_source_digest_and_generator_output_drift(self): + for field, error in (("source_file_sha256", "source file digests"), + ("generated_file_sha256", "graph regeneration")): + manifest = copy.deepcopy(self.manifest) + manifest[field][next(iter(manifest[field]))] = "0" * 64 + with self.subTest(field=field), self.assertRaisesRegex(SystemExit, error): + historical.verify(ROOT, manifest) + + def test_should_detect_a_changed_capture_recipe(self): + self.manifest["capture_test_sha256"] = "0" * 64 + with self.assertRaisesRegex(SystemExit, "capture recipe changed"): + historical.verify(ROOT, self.manifest) + + +class CapturePreparationTests(unittest.TestCase): + def test_should_refuse_to_export_into_the_repository_or_an_existing_directory(self): + with tempfile.TemporaryDirectory() as scratch: + root = Path(scratch, "repository") + root.mkdir() + for destination in (root / "new-sdk", Path(scratch)): + with self.subTest(destination=destination), self.assertRaisesRegex(SystemExit, "new directory outside"): + historical.prepare_sdk(root, destination) + self.assertEqual(list(root.iterdir()), []) + + def test_should_export_historical_models_only_into_a_disposable_sdk_copy(self): + with tempfile.TemporaryDirectory() as scratch: + root, destination = Path(scratch, "repository"), Path(scratch, "capture-sdk") + sdk = root / historical.SDK + for folder in ("Sources/SwiftDashSDK", "SwiftTests/SwiftDashSDKTests", "DashSDKFFI.xcframework"): + (sdk / folder).mkdir(parents=True) + (sdk / "Package.swift").write_text("// package\n") + (sdk / "Sources/SwiftDashSDK/Current.swift").write_text("// current\n") + template = root / historical.CAPTURE_TEST + template.parent.mkdir(parents=True) + template.write_text("// capture test\n") + generated = f"{historical.freeze.OUT_DIR}/DashSchemaSnapshotV1+Schema.swift" + with mock.patch.object(historical, "verify", return_value={generated: "// historical\n"}): + historical.prepare_sdk(root, destination) + self.assertFalse((root / generated).exists()) + self.assertEqual((destination / Path(generated).relative_to(historical.SDK)).read_text(), "// historical\n") + self.assertEqual((destination / "Sources/SwiftDashSDK/Current.swift").read_text(), "// current\n") + self.assertEqual((destination / "SwiftTests/SwiftDashSDKTests/DashHistoricalFixtureCaptureTests.swift").read_bytes(), template.read_bytes()) + self.assertEqual((destination / "DashSDKFFI.xcframework").resolve(), (sdk / "DashSDKFFI.xcframework").resolve()) + + +if __name__ == "__main__": + unittest.main() From e1bf24d686c4f378dd696b3d8d1ded187b422d73 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 12:42:00 +0200 Subject: [PATCH 06/18] fix(swift-sdk): limit legacy detection and locking to bridge stores --- .../Persistence/DashLegacySchemaBridge.swift | 27 +++++++++---- .../DashLegacySchemaMigrationTests.swift | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index 78ad2800387..8fcf9e73d79 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -38,17 +38,22 @@ enum DashLegacySchemaBridge { } return try ordinary() } + let root = backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + if !FileManager.default.fileExists(atPath: marker.path) { + // Detection must not impose bridge-specific metadata or locking + // requirements on stores handled by SwiftData's ordinary path. + guard let source = try? identity(at: url), + (try? needsBridge(source, plan: plan)) == true else { return try ordinary() } + } let lock = try StoreLock(url: url) defer { lock.close() } - let root = backupDirectory(for: url) try SQLite.recoverRollbackJournal(at: url) try recoverIfNeeded(at: url, root: root) - let source = try identity(at: url) - // Newer/unknown identifiers are never downgraded or treated as legacy. - guard source.versions == ["1.0.0"] else { return try ordinary() } - for registered in plan.schemas where version(registered.versionIdentifier) == "1.0.0" { - if source == (try identity(for: registered)) { return try ordinary() } - } + // Another opener may have finished migration before this lock was + // acquired. Recovery errors above remain fatal; ordinary detection does not. + guard let source = try? identity(at: url), + (try? needsBridge(source, plan: plan)) == true else { return try ordinary() } guard configuration.allowsSave else { throw SQLite.Failure.unsupported("The store is read-only") } let permitted = Set(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)) let required: Set = ["PersistentWallet", "PersistentAccount", "PersistentTransaction", "PersistentTxo"] @@ -142,6 +147,14 @@ enum DashLegacySchemaBridge { url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".legacy-v2-backups", isDirectory: true) } + private static func needsBridge(_ source: Identity, plan: any SchemaMigrationPlan.Type) throws -> Bool { + guard source.versions == ["1.0.0"] else { return false } + for registered in plan.schemas where version(registered.versionIdentifier) == "1.0.0" { + if source == (try identity(for: registered)) { return false } + } + return true + } + private static func recoverIfNeeded(at url: URL, root: URL) throws { let marker = root.appendingPathComponent("active.json") guard FileManager.default.fileExists(atPath: marker.path) else { return } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 6a615148c68..6e448f6043a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -1,4 +1,5 @@ import CoreData +import Darwin import Foundation import SQLite3 import SwiftData @@ -89,10 +90,48 @@ final class DashLegacySchemaMigrationTests: XCTestCase { try withStore(baseline: true) { url in _ = try open(url, hooks: .init(visit: { _, _ in XCTFail("Known V1 must not bridge") })) XCTAssertTrue(try operationDirectories(url).isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + ".legacy-v2.lock")) } _ = try DashModelContainer.createInMemory() } + func testOrdinaryStoresIgnoreBridgeLockButLegacyAndPendingRecoveryRequireIt() throws { + try withStore(baseline: true) { url in + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + try autoreleasepool { _ = try open(url) } // Known V1. + let current = try open(url) // Current V2. + XCTAssertEqual(try current.mainContext.fetchCount(FetchDescriptor()), 1) + } + for interrupted in [false, true] { + try withStore { url in + if interrupted { + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + } + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + XCTAssertThrowsError(try open(url)) + } + } + } + + func testMissingBridgeMetadataUsesOrdinaryOpeningWithoutCreatingBridgeFiles() throws { + try withStore(baseline: true) { url in + var metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) + metadata.removeValue(forKey: "NSStoreModelVersionChecksumKey") + try NSPersistentStoreCoordinator.setMetadata(metadata, type: .sqlite, at: url) + let container = try open(url, hooks: .init(visit: { _, _ in XCTFail("Must use the ordinary plan") })) + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + ".legacy-v2.lock")) + } + } + func testHistoricalStorePreservesDataDefaultsBackupAndDoesNotBridgeAgain() throws { try withStore { url in try autoreleasepool { From 2fe5826b5251e4e6a3048e044e4c7e407f94c986 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 12:43:30 +0200 Subject: [PATCH 07/18] fix(swift-sdk): recover migrations without scratch copies --- .../Persistence/DashLegacySchemaBridge.swift | 37 ++++++++++++------- .../Persistence/DashLegacyStoreSQLite.swift | 24 ++++++++++++ .../DashLegacySchemaMigrationTests.swift | 33 +++++++++++++++++ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index 8fcf9e73d79..6de0e4f73b9 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -23,6 +23,7 @@ enum DashLegacySchemaBridge { let operation: UUID let source: Identity let destination: Identity + let destinationData: SQLite.StoreEvidence? } static func open(configuration: ModelConfiguration, schema: Schema, @@ -115,7 +116,8 @@ enum DashLegacySchemaBridge { try rejectExternalStorage(at: candidate) try SQLite.integrityCheck(candidate) let destination = try identity(at: candidate) - let journal = Journal(formatVersion: 1, operation: operation, source: source, destination: destination) + let journal = Journal(formatVersion: 2, operation: operation, source: source, destination: destination, + destinationData: try SQLite.evidence(at: candidate)) // Publish the marker only after both copy filenames and their data are // durable. The marker lives in the parent directory, synced separately. for fileURL in [backup, candidate] { @@ -159,26 +161,35 @@ enum DashLegacySchemaBridge { let marker = root.appendingPathComponent("active.json") guard FileManager.default.fileExists(atPath: marker.path) else { return } let journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: marker)) - guard journal.formatVersion == 1 else { throw SQLite.Failure.unsupported("Unknown migration recovery format") } - let directory = root.appendingPathComponent(journal.operation.uuidString, isDirectory: true) - let backup = directory.appendingPathComponent("original.store") - guard try identity(at: backup) == journal.source else { - throw SQLite.Failure.unsupported("Migration backup is missing or has changed") + guard [1, 2].contains(journal.formatVersion) else { + throw SQLite.Failure.unsupported("Unknown migration recovery format") } + let directory = root.appendingPathComponent(journal.operation.uuidString, isDirectory: true) // SQLite commits or rolls back the entire page replacement, including // WAL recovery. We never restore a backup over potentially newer writes. let current = try identity(at: url) if current == journal.destination { - let candidate = directory.appendingPathComponent("candidate.store") - guard try identity(at: candidate) == journal.destination else { - throw SQLite.Failure.unsupported("Validated migration candidate is missing or has changed") - } try SQLite.integrityCheck(url) - // Explicit later stages may intentionally transform legacy values. - // Compare with the already validated final candidate, not the old graph. - try SQLite.validatePreservation(from: candidate, to: url) + if journal.formatVersion == 2 { + guard let expected = journal.destinationData, + try SQLite.evidence(at: url) == expected else { + throw SQLite.Failure.unsupported("Installed migration data differs from the validated candidate") + } + } else { + // Older journals require their candidate as the data evidence. + // Schema identity and SQLite integrity alone cannot prove preservation. + let candidate = directory.appendingPathComponent("candidate.store") + guard try identity(at: candidate) == journal.destination else { + throw SQLite.Failure.unsupported("Validated migration candidate is missing or has changed") + } + try SQLite.validatePreservation(from: candidate, to: url) + } } else if current != journal.source { throw SQLite.Failure.unsupported("Store changed while migration recovery was pending") + } else { + // The original remains authoritative even if scratch copies were + // removed. Validate it, clear the attempt, then take fresh copies. + try SQLite.integrityCheck(url) } try clearJournal(at: root) if current == journal.source { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift index 3ff407965e0..ea5d28c789a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -133,6 +133,30 @@ enum DashLegacyStoreSQLite { let tables: [String: Table] } + struct TableEvidence: Codable, Equatable { + let columns: [String: String] + let rowsDigest: String + } + struct StoreEvidence: Codable, Equatable { + let entities: [String] + let tables: [String: TableEvidence] + } + + /// Durable evidence of the validated final candidate, independent of + /// SQLite page layout, WAL state and disposable migration copy files. + static func evidence(at url: URL) throws -> StoreEvidence { + let connection = try Connection(url, writable: false) + let layout = try layout(connection) + var tables: [String: TableEvidence] = [:] + for (name, table) in layout.tables { + tables[name] = TableEvidence( + columns: table.columns.mapValues(\.declaredType), + rowsDigest: try rowsDigest(connection, table: table, + names: table.columns.keys.sorted(), entities: layout.entities)) + } + return StoreEvidence(entities: layout.entities.values.sorted(), tables: tables) + } + /// Compare every original application column and typed cell, including /// relationship foreign keys/join rows. Extra destination columns/tables /// are allowed; removals, type conversions and changed values are not. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 6e448f6043a..965b3c11a9c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -251,6 +251,39 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testRecoveryWorksWithoutScratchFilesBeforeAndAfterCommit() throws { + for phase: DashLegacySchemaBridge.Phase in [.beforeInstall, .afterCommit] { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { current, _ in + if current == phase { throw Injected.stop } + }))) + for directory in try operationDirectories(url) { + try FileManager.default.removeItem(at: directory) + } + let recovered = try open(url) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: + DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json").path)) + } + } + } + + func testMissingCandidateCannotHideChangedInstalledRows() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + for directory in try operationDirectories(url) { + try FileManager.default.removeItem(at: directory) + } + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("DELETE FROM ZPERSISTENTINDEX") + XCTAssertThrowsError(try open(url)) + XCTAssertTrue(FileManager.default.fileExists(atPath: + DashLegacySchemaBridge.backupDirectory(for: url).appendingPathComponent("active.json").path)) + } + } + func testCorruptionExternalStorageAndNewerUnknownVersionDoNotBridge() throws { try withStore { url in try Data("not a database".utf8).write(to: url) From 9597c0f5173c181ff6e90d7c665ea79483aa1ebd Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 12:44:58 +0200 Subject: [PATCH 08/18] fix(swift-sdk): reclaim migration backups after a successful reopen --- packages/swift-sdk/SCHEMA_RELEASES.md | 9 +++++-- .../Persistence/DashLegacySchemaBridge.swift | 25 +++++++++++++++++-- .../DashLegacySchemaMigrationTests.swift | 13 +++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index f249b5fca34..616d19ff77f 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -35,8 +35,13 @@ use their ordinary migration plan. Recovery files live beside the original store under `.legacy-v2-backups/`. A successful bridge retains an -`original.store` backup. The `active.json` journal records an interrupted -installation; the next open reconciles it before exposing a container. These +`original.store` backup through that launch. A later successful ordinary open +reclaims completed backup directories; failed opens and pending migrations +never trigger cleanup. Cleanup failures do not prevent opening the wallet. +The `active.json` journal records an interrupted installation and a fingerprint +of the validated final data; the next open reconciles it before exposing a +container, even if scratch copies were removed. Older journals still require +their candidate when validating a committed installation. These are local wallet data, protected like the original store and excluded from device backup. Do not upload them as release fixtures or edit the recovery journal to bypass a failure. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index 6de0e4f73b9..ac78f292a01 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -44,8 +44,14 @@ enum DashLegacySchemaBridge { if !FileManager.default.fileExists(atPath: marker.path) { // Detection must not impose bridge-specific metadata or locking // requirements on stores handled by SwiftData's ordinary path. - guard let source = try? identity(at: url), - (try? needsBridge(source, plan: plan)) == true else { return try ordinary() } + let needsMigration = (try? identity(at: url)).flatMap { try? needsBridge($0, plan: plan) } ?? false + // Recheck after reading identity: a concurrent bridge publishes its + // journal before the store can change to the destination schema. + if !needsMigration && !FileManager.default.fileExists(atPath: marker.path) { + let container = try ordinary() + reclaimCompletedBackups(at: root) + return container + } } let lock = try StoreLock(url: url) defer { lock.close() } @@ -157,6 +163,21 @@ enum DashLegacySchemaBridge { return true } + /// A backup survives the migration/recovery launch. Reclaim it only after + /// a later ordinary open succeeds and no migration journal is pending. + /// Cleanup failure affects disk usage, never the ability to open the wallet. + private static func reclaimCompletedBackups(at root: URL) { + let marker = root.appendingPathComponent("active.json") + guard !FileManager.default.fileExists(atPath: marker.path), + let entries = try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return } + for entry in entries where UUID(uuidString: entry.lastPathComponent) != nil { + guard let attributes = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), + attributes.isDirectory == true, attributes.isSymbolicLink != true else { continue } + try? FileManager.default.removeItem(at: entry) + } + } + private static func recoverIfNeeded(at url: URL, root: URL) throws { let marker = root.appendingPathComponent("active.json") guard FileManager.default.fileExists(atPath: marker.path) else { return } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 965b3c11a9c..63d9b2a5af7 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -148,7 +148,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { "wOm/tD2jkxoKsyP7GFXVNeebjqpLZbKZZ4EqYLkwlMk=") let reopened = try open(url, hooks: .init(visit: { _, _ in XCTFail("Completed bridge must not repeat") })) try verifyRows(reopened.mainContext, walletName: "saved after migration") - XCTAssertEqual(try operationDirectories(url).count, 1) + XCTAssertTrue(try operationDirectories(url).isEmpty, "A later successful open reclaims the retained backup") } } @@ -284,6 +284,17 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testFailedOrdinaryOpenDoesNotReclaimRetainedBackup() throws { + try withStore { url in + try autoreleasepool { _ = try open(url) } + let directories = try operationDirectories(url) + XCTAssertEqual(directories.count, 1) + try Data("unreadable store".utf8).write(to: url) + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(try operationDirectories(url), directories) + } + } + func testCorruptionExternalStorageAndNewerUnknownVersionDoNotBridge() throws { try withStore { url in try Data("not a database".utf8).write(to: url) From 27b943a82c5fdab562e3e61b6bf82df460eadd88 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 12:45:39 +0200 Subject: [PATCH 09/18] fix(swift-sdk): verify the migrated SQLite journal mode --- .../Persistence/DashLegacyStoreSQLite.swift | 6 ++++- .../DashLegacySchemaMigrationTests.swift | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift index ea5d28c789a..951b350fd26 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -116,7 +116,11 @@ enum DashLegacyStoreSQLite { guard sqlite3_wal_checkpoint_v2(connection.handle, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil) == SQLITE_OK else { throw Failure.database("Cannot close the migrated WAL") } - try connection.execute("PRAGMA journal_mode=DELETE") + var modes: [String] = [] + try connection.query("PRAGMA journal_mode=DELETE") { modes.append(string($0, 0).lowercased()) } + guard modes == ["delete"] else { + throw Failure.database("Migrated store did not leave WAL mode") + } } struct Column: Equatable { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 63d9b2a5af7..cbee27b5257 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -184,6 +184,30 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testCheckpointRejectsBusyWALAndConfirmsDeleteMode() throws { + try withStore { url in + try autoreleasepool { + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL") + try writer.execute("PRAGMA wal_autocheckpoint=0") + let reader = try DashLegacyStoreSQLite.Connection(url, writable: false) + try reader.execute("BEGIN") + try reader.query("SELECT ZNAME FROM ZPERSISTENTWALLET") { _ in } + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='checkpointed'") + XCTAssertThrowsError(try DashLegacyStoreSQLite.checkpoint(url)) + try reader.execute("ROLLBACK") + } + try DashLegacyStoreSQLite.checkpoint(url) + let connection = try DashLegacyStoreSQLite.Connection(url, writable: false) + var mode = "" + try connection.query("PRAGMA journal_mode") { + mode = String(cString: sqlite3_column_text($0, 0)) + } + XCTAssertEqual(mode, "delete") + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path + "-wal")) + } + } + func testChangedSourceIsNotOverwrittenAndRetryPreservesNewWrite() throws { try withStore { url in XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, source in From 852d825b4393e5bd8966544f74288a829a0093f4 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 12:55:14 +0200 Subject: [PATCH 10/18] fix(swift-sdk): open and migrate stores on a dedicated queue --- packages/swift-sdk/SCHEMA_RELEASES.md | 5 +- .../Persistence/DashModelContainer.swift | 21 +++++++ .../DashLegacySchemaMigrationTests.swift | 61 ++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 616d19ff77f..8dfd531e08e 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -29,7 +29,10 @@ container failure and never resets the store. Applications with their own database paths must use `DashModelContainer.create(url:)` before opening the store elsewhere. Direct `ModelContainer` construction bypasses this compatibility bridge. The iOS host -uses the shared factory while retaining its existing store path and lifecycle. +uses `DashModelContainer.createAsync(url:)` while retaining its existing store +path and lifecycle. This async variant opens/migrates on a dedicated serial +queue and returns only the Sendable container; callers create and use contexts +on their owning actor. Coalesce concurrent opens of the same URL in the app. The bridge is for local stores; CloudKit and in-memory containers continue to use their ordinary migration plan. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 2065f1a37eb..cb5c0ce54e7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -1,4 +1,5 @@ import Foundation +import Dispatch import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence @@ -133,6 +134,26 @@ public enum DashModelContainer { return try makeContainer(configuration: modelConfiguration) } + private static let storeOpenQueue = DispatchQueue( + label: "org.dash.swift-sdk.store-open", qos: .userInitiated) + + /// Open and migrate a local store without blocking the caller's actor. + /// Only the Sendable container crosses the queue; create/use contexts on + /// their owning actor after this returns. Callers sharing a URL should + /// coalesce in-flight opens and retain one container for that store. + public static func createAsync(url: URL) async throws -> ModelContainer { + try await withCheckedThrowingContinuation { continuation in + storeOpenQueue.async { + do { + let container = try autoreleasepool { try create(url: url) } + continuation.resume(returning: container) + } catch { + continuation.resume(throwing: error) + } + } + } + } + /// The one place a persistent container is built: the live schema is /// constructed first (`schema`), and the container then runs the /// migration plan over it. That order is what the frozen versions are diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index cbee27b5257..d2c190c515c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -34,15 +34,20 @@ final class DashLegacySchemaMigrationTests: XCTestCase { private enum Injected: Error { case stop } private func withStore(baseline: Bool = false, _ body: (URL) throws -> Void) throws { + let (directory, url) = try makeStore(baseline: baseline) + defer { try? FileManager.default.removeItem(at: directory) } + try autoreleasepool { try body(url) } + } + + private func makeStore(baseline: Bool = false) throws -> (URL, URL) { let source = try XCTUnwrap(Bundle.module.url( forResource: baseline ? "dash-v1" : "fixture", withExtension: "store", subdirectory: baseline ? "Fixtures/SchemaStores" : "Fixtures/SchemaStores/legacy-fd8d8d13e5")) let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("DashModel.store") try FileManager.default.copyItem(at: source, to: url) - try autoreleasepool { try body(url) } + return (directory, url) } private func open(_ url: URL, hooks: DashLegacySchemaBridge.Hooks = .init()) throws -> ModelContainer { let schema = DashModelContainer.schema @@ -417,4 +422,56 @@ final class DashLegacySchemaMigrationTests: XCTestCase { try copy.execute("ALTER TABLE ZPERSISTENTWALLET DROP COLUMN T") XCTAssertThrowsError(try DashLegacyStoreSQLite.validatePreservation(from: original, to: candidate), "Removed original columns must fail validation") } + + func testLargeHistoricalStoreMigratesAsynchronouslyWhileMainActorRemainsResponsive() async throws { + let (directory, url) = try makeStore() + defer { try? FileManager.default.removeItem(at: directory) } + // Synthetic storage load only: these payloads are not broadcastable + // transactions or a production wallet. Populate the pinned old layout. + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + try connection.execute(""" + WITH RECURSIVE rows(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM rows WHERE n<10000) + INSERT INTO ZPERSISTENTTRANSACTION + (Z_PK,Z_ENT,Z_OPT,ZBLOCKHEIGHT,ZBLOCKPOSITION,ZBLOCKTIMESTAMP,ZCONTEXT,ZDIRECTION, + ZFIRSTSEEN,ZHASBLOCKPOSITION,ZNETAMOUNT,ZPROVIDERCOLLATERALVOUT,ZTRANSACTIONTYPEKIND, + ZCREATEDAT,ZLASTUPDATED,ZLABEL,ZTRANSACTIONTYPE,ZTRANSACTIONDATA,ZTXID) + SELECT n,(SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME='PersistentTransaction'),1, + 0,0,0,0,0,n,0,0,0,255,0,0,'synthetic','Standard',zeroblob(8192), + CAST(printf('%032d',n) AS BLOB) FROM rows; + UPDATE Z_PRIMARYKEY SET Z_MAX=10000 WHERE Z_NAME='PersistentTransaction'; + """) + } + let bytes = (try FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?.int64Value ?? 0 + let heartbeat = Task { @MainActor in + var ticks = 0 + var longestGap = 0.0 + var previous = Date.timeIntervalSinceReferenceDate + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 10_000_000) + let now = Date.timeIntervalSinceReferenceDate + longestGap = max(longestGap, now - previous) + previous = now + ticks += 1 + } + return (ticks, longestGap) + } + defer { heartbeat.cancel() } + let started = Date.timeIntervalSinceReferenceDate + let container = try await DashModelContainer.createAsync(url: url) + let elapsed = Date.timeIntervalSinceReferenceDate - started + heartbeat.cancel() + let (ticks, longestGap) = await heartbeat.value + XCTAssertGreaterThan(ticks, 1, "The main actor must keep executing while migration runs") + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 10_000) + try verifyRows(container.mainContext) + let report = "Legacy migration benchmark: bytes=\(bytes) transactions=10000 seconds=\(elapsed) mainTicks=\(ticks) maxMainGap=\(longestGap)" + Swift.print(report) + let attachment = XCTAttachment(string: report) + attachment.name = "legacy-migration-benchmark" + attachment.lifetime = .keepAlways + add(attachment) + let reopened = try await DashModelContainer.createAsync(url: url) + XCTAssertEqual(try reopened.mainContext.fetchCount(FetchDescriptor()), 10_000) + } } From 889414220931f3daaad9bbfaad207cc8e962f251 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 14:10:29 +0200 Subject: [PATCH 11/18] fix(swift-sdk): handle unreadable release API responses --- .../scripts/freeze_appstore_release.py | 7 ++-- .../scripts/test_freeze_appstore_release.py | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index b8744a807f1..38b35b08484 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -12,6 +12,7 @@ import base64 import contextlib import hashlib +import http.client import json import os from pathlib import Path @@ -210,11 +211,13 @@ def request(self, method, path, payload=None): self.sleep(min(30, 2 ** attempt)) continue raise ReleaseError(f"GitHub {method} failed with HTTP {error.code}") from error - except urllib.error.URLError as error: + except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException, + json.JSONDecodeError, UnicodeDecodeError) as error: if method == "GET" and attempt < 3: self.sleep(2 ** attempt) continue - raise ReleaseError("GitHub request failed; retry the workflow to reconcile its state") from error + raise ReleaseError(f"GitHub {method} response could not be read or decoded; " + "retry the workflow to reconcile its state") from error def pull_requests(self, branch): results = [] diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index d3c9292ad24..ec698a27b72 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -2,6 +2,7 @@ import contextlib import hashlib +import http.client import io import json import os @@ -184,6 +185,38 @@ def test_does_not_retry_ambiguous_post(self): worker.GitHub("secret", opener=opener).request("POST", "pulls", {}) self.assertEqual(opener.call_count, 1) + def test_retries_invalid_json_and_encoding_on_reads(self): + for broken in (b'{truncated', b'gateway', b'\xff'): + with self.subTest(broken=broken): + opener = mock.Mock(side_effect=[io.BytesIO(broken), io.BytesIO(b'[]')]) + sleep = mock.Mock() + self.assertEqual(worker.GitHub("secret", opener=opener, sleep=sleep).request("GET", "pulls"), []) + self.assertEqual(opener.call_count, 2) + sleep.assert_called_once_with(1) + + def test_invalid_read_response_has_bounded_retries_and_a_clear_error(self): + opener = mock.Mock(side_effect=lambda *_args, **_kwargs: io.BytesIO(b'{truncated')) + sleep = mock.Mock() + with self.assertRaisesRegex(worker.ReleaseError, "GET response could not be read or decoded"): + worker.GitHub("secret", opener=opener, sleep=sleep).request("GET", "pulls") + self.assertEqual(opener.call_count, 4) + self.assertEqual(sleep.call_args_list, [mock.call(1), mock.call(2), mock.call(4)]) + + def test_retries_connection_failure_while_reading_response_body(self): + response = mock.MagicMock() + response.__enter__.return_value.read.side_effect = http.client.IncompleteRead(b'partial') + opener = mock.Mock(side_effect=[response, io.BytesIO(b'[]')]) + self.assertEqual(worker.GitHub("secret", opener=opener, sleep=lambda _: None).request("GET", "pulls"), []) + self.assertEqual(opener.call_count, 2) + + def test_does_not_repeat_writes_after_an_unreadable_response(self): + for method in ("POST", "PATCH"): + with self.subTest(method=method): + opener = mock.Mock(return_value=io.BytesIO(b'{truncated')) + with self.assertRaisesRegex(worker.ReleaseError, "reconcile"): + worker.GitHub("secret", opener=opener).request(method, "pulls", {}) + self.assertEqual(opener.call_count, 1) + def test_pr_listing_is_paginated(self): api = worker.GitHub("secret") api.request = mock.Mock(side_effect=[[{}] * 100, [{"number": 101}]]) From a8009209b51c112f655c24965ac561c83d169d78 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 14:10:53 +0200 Subject: [PATCH 12/18] fix(swift-sdk): check migration storage and clarify recovery failures --- packages/swift-sdk/SCHEMA_RELEASES.md | 36 ++++++- .../Persistence/DashLegacySchemaBridge.swift | 54 ++++++++-- .../Persistence/DashLegacyStoreSQLite.swift | 47 +++++++-- .../Persistence/DashModelContainer.swift | 10 +- .../DashLegacySchemaMigrationTests.swift | 99 +++++++++++++++++-- 5 files changed, 216 insertions(+), 30 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 8dfd531e08e..7a1d6f5bfbc 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -24,17 +24,35 @@ existing stored values and relationship rows survived, and reopen it through the ordinary migration plan before installing it. The backup is retained for recovery. Corruption, removed fields/entities, changed stored data, and newer unknown schema versions are errors; this is not a general retry for any -container failure and never resets the store. +container failure and never resets the store. Preservation is deliberately +strict: existing SQLite primary keys, foreign keys and join rows must survive +unchanged (entity ordinals are normalized by name). Even a semantically equivalent +Core Data migration that renumbers primary keys is rejected; such a layout +requires an explicit, separately validated migration rather than relaxing this +bridge's data-loss checks. + +Before making full-size copies, the bridge checks free space on the store's +volume. Its conservative estimate is four times the combined main-file and WAL +size, plus the larger of 64 MiB or half that combined size. This budgets the two +copies, inferred-migration/promotion journals and schema/index growth. It is a +preflight estimate, not a reservation: other processes or larger-than-estimated +growth can still exhaust space, and SQLite errors remain fatal without replacing +the original. Insufficient headroom reports the needed and available space and +asks the user to free device storage and retry; it does not delete wallet data. Applications with their own database paths must use -`DashModelContainer.create(url:)` before opening the store elsewhere. Direct +`DashModelContainer.create(url:)` or its async twin before opening the store +elsewhere. Both synchronous `create` overloads may block for seconds while +opening or migrating a large database and should not run on a UI actor. Direct `ModelContainer` construction bypasses this compatibility bridge. The iOS host uses `DashModelContainer.createAsync(url:)` while retaining its existing store path and lifecycle. This async variant opens/migrates on a dedicated serial queue and returns only the Sendable container; callers create and use contexts on their owning actor. Coalesce concurrent opens of the same URL in the app. -The bridge is for local stores; CloudKit and in-memory containers continue to -use their ordinary migration plan. +The bridge is intentionally local-only: copying +and replacing SQLite does not establish preservation of CloudKit's synchronization +state. CloudKit and in-memory containers use their ordinary migration plan; +unknown CloudKit schemas require a separately supported migration. Recovery files live beside the original store under `.legacy-v2-backups/`. A successful bridge retains an @@ -49,6 +67,16 @@ are local wallet data, protected like the original store and excluded from device backup. Do not upload them as release fixtures or edit the recovery journal to bypass a failure. +A missing primary database with a pending journal requires deliberate recovery. +The bridge never deletes or renames that primary file, so its absence is not a +normal interrupted-install state; it may be an intentional external reset. The +SDK preserves the journal and any copies and reports their location. It does +not silently restore `original.store` (which could be older than a committed +installation) or clear the marker and create an empty database. With the app +closed, restore the authoritative original from a verified backup, or use support +to identify an appropriate recovery source. If no source can be verified, retain +the evidence and stop; do not edit the journal to force startup. + The historical regression fixture reconstructs Platform `fd8d8d13e5d7cea17b00df5974934ab1910e8039` from the same checkout pair as iOS `8094751eb2be8d52b57da3589fdd2ae2dcd0ecc6` in diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index ac78f292a01..d37e370b78d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -12,6 +12,7 @@ enum DashLegacySchemaBridge { enum Phase { case afterSnapshot, afterMigration, beforeInstall, writeLocked, afterCommit } struct Hooks { var visit: (Phase, URL) throws -> Void = { _, _ in } + var availableCapacity: (URL) throws -> Int64 = DashLegacySchemaBridge.availableCapacity(at:) } struct Identity: Codable, Equatable { let versions: [String] @@ -33,14 +34,10 @@ enum DashLegacySchemaBridge { try ModelContainer(for: schema, migrationPlan: plan, configurations: [configuration]) } guard !configuration.isStoredInMemoryOnly else { return try ordinary() } - guard FileManager.default.fileExists(atPath: url.path) else { - if FileManager.default.fileExists(atPath: backupDirectory(for: url).appendingPathComponent("active.json").path) { - throw SQLite.Failure.unsupported("Original database is missing while migration recovery is pending") - } - return try ordinary() - } let root = backupDirectory(for: url) let marker = root.appendingPathComponent("active.json") + if !FileManager.default.fileExists(atPath: url.path), + !FileManager.default.fileExists(atPath: marker.path) { return try ordinary() } if !FileManager.default.fileExists(atPath: marker.path) { // Detection must not impose bridge-specific metadata or locking // requirements on stores handled by SwiftData's ordinary path. @@ -55,6 +52,13 @@ enum DashLegacySchemaBridge { } let lock = try StoreLock(url: url) defer { lock.close() } + // Recheck under the recovery lock. Our transactional installation never + // removes the primary file, so absence may be an intentional external + // reset. Neither resurrect an older backup nor create an empty store. + guard FileManager.default.fileExists(atPath: url.path) else { + throw SQLite.Failure.unsupported( + "The original database is missing while migration recovery is pending. Recovery files remain at \(root.path). Restore the original database from a verified backup with the app closed, or contact support for deliberate recovery. Do not delete the journal or create an empty database.") + } try SQLite.recoverRollbackJournal(at: url) try recoverIfNeeded(at: url, root: root) // Another opener may have finished migration before this lock was @@ -69,6 +73,11 @@ enum DashLegacySchemaBridge { throw SQLite.Failure.unsupported("The old model contains unsupported or missing entities") } try rejectExternalStorage(at: url) + let requiredSpace = try requiredFreeSpace(at: url) + let availableSpace = max(0, try hooks.availableCapacity(url.deletingLastPathComponent())) + guard availableSpace >= requiredSpace else { + throw SQLite.Failure.insufficientDiskSpace(required: requiredSpace, available: availableSpace) + } let operation = UUID() let directory = root.appendingPathComponent(operation.uuidString, isDirectory: true) var directoryAttributes = try protectionAttributes(like: url) @@ -155,6 +164,39 @@ enum DashLegacySchemaBridge { url.deletingLastPathComponent().appendingPathComponent(url.lastPathComponent + ".legacy-v2-backups", isDirectory: true) } + /// Conservative estimate, not a reservation: two complete copies plus + /// inferred-migration and promotion journals, with room for new columns and + /// indexes. Include WAL bytes because the backup incorporates committed WAL. + static func requiredFreeSpace(at url: URL) throws -> Int64 { + var sourceBytes: Int64 = 0 + for suffix in ["", "-wal"] { + let path = url.path + suffix + if suffix.isEmpty || FileManager.default.fileExists(atPath: path) { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard let size = (attributes[.size] as? NSNumber)?.int64Value, size >= 0 else { + throw SQLite.Failure.database("Cannot estimate migration storage requirements") + } + let sum = sourceBytes.addingReportingOverflow(size) + guard !sum.overflow else { return Int64.max } + sourceBytes = sum.partialValue + } + } + let copiesAndJournals = sourceBytes.multipliedReportingOverflow(by: 4) + let margin = max(64 * 1024 * 1024, sourceBytes / 2) + let total = copiesAndJournals.partialValue.addingReportingOverflow(margin) + return copiesAndJournals.overflow || total.overflow ? Int64.max : total.partialValue + } + + private static func availableCapacity(at directory: URL) throws -> Int64 { + let values = try directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + if let capacity = values.volumeAvailableCapacityForImportantUsage { return capacity } + let attributes = try FileManager.default.attributesOfFileSystem(forPath: directory.path) + guard let capacity = (attributes[.systemFreeSize] as? NSNumber)?.int64Value else { + throw SQLite.Failure.database("Cannot determine available storage; check device storage and retry") + } + return capacity + } + private static func needsBridge(_ source: Identity, plan: any SchemaMigrationPlan.Type) throws -> Bool { guard source.versions == ["1.0.0"] else { return false } for registered in plan.schemas where version(registered.versionIdentifier) == "1.0.0" { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift index 951b350fd26..4e5e1fe6784 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -8,12 +8,20 @@ import SQLite3 enum DashLegacyStoreSQLite { enum Failure: Error, LocalizedError { case database(String) + case sqlite(operation: String, code: Int32, reason: String) + case insufficientDiskSpace(required: Int64, available: Int64) case unsupported(String) case sourceChanged var errorDescription: String? { switch self { case .database(let reason): return "Legacy database migration failed: \(reason)" + case .sqlite(let operation, let code, let reason): + return "Legacy database migration failed during \(operation) (SQLite \(code)): \(reason)" + case .insufficientDiskSpace(let required, let available): + let needed = ByteCountFormatter.string(fromByteCount: required, countStyle: .file) + let free = ByteCountFormatter.string(fromByteCount: available, countStyle: .file) + return "Legacy database migration needs approximately \(needed) of free space; \(free) is available. Free device storage and retry. The original database has not been replaced." case .unsupported(let reason): return "Legacy database migration is not safe: \(reason)" case .sourceChanged: return "The database changed during migration. Close other users of the store and retry." } @@ -27,9 +35,9 @@ enum DashLegacyStoreSQLite { let flags = writable ? SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0) : SQLITE_OPEN_READONLY let status = sqlite3_open_v2(url.path, &result, flags | SQLITE_OPEN_FULLMUTEX, nil) guard status == SQLITE_OK, let result else { - let reason = result.map { String(cString: sqlite3_errmsg($0)) } ?? "Cannot open SQLite store" + let failure = sqliteFailure(handle: result, operation: "opening a database", status: status) sqlite3_close(result) - throw Failure.database(reason) + throw failure } handle = result sqlite3_busy_timeout(handle, 0) @@ -77,25 +85,48 @@ enum DashLegacyStoreSQLite { // C backup handles borrow both Swift connection owners, including on errors. try withExtendedLifetime((input, output)) { guard let backup = sqlite3_backup_init(output.handle, "main", input.handle, "main") else { - throw Failure.database(String(cString: sqlite3_errmsg(output.handle))) + throw sqliteFailure(handle: output.handle, operation: "initializing the database copy", + status: sqlite3_errcode(output.handle)) } var finished = false defer { if !finished { sqlite3_backup_finish(backup) } } // Zero pages still acquires the destination write lock. No destination // connection APIs may run until backup_finish; raw file reads are safe. - guard sqlite3_backup_step(backup, 0) == SQLITE_OK else { - throw Failure.database("Database is busy; cannot acquire migration write lock") + let lockStatus = sqlite3_backup_step(backup, 0) + guard lockStatus == SQLITE_OK else { + // The destination cannot be queried until backup_finish. It + // propagates the backup error to the destination connection. + sqlite3_backup_finish(backup) + finished = true + throw sqliteFailure(handle: output.handle, operation: "acquiring the migration write lock", + status: lockStatus) } try lockedDestinationCheck?() - guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { - throw Failure.database("SQLite could not complete the transactional database copy") + let copyStatus = sqlite3_backup_step(backup, -1) + guard copyStatus == SQLITE_DONE else { + sqlite3_backup_finish(backup) + finished = true + throw sqliteFailure(handle: output.handle, operation: "copying the database", + status: copyStatus) } let status = sqlite3_backup_finish(backup) finished = true - guard status == SQLITE_OK else { throw Failure.database("SQLite could not commit the database copy") } + guard status == SQLITE_OK else { + throw sqliteFailure(handle: output.handle, operation: "committing the database copy", status: status) + } } } + private static func sqliteFailure(handle: OpaquePointer?, operation: String, status: Int32) -> Failure { + let extended = handle.map { sqlite3_extended_errcode($0) } ?? status + // Use the connection's extended code only if it describes this error. + // For example, SQLITE_BUSY from backup_step may leave no connection error. + let matches = (extended & 0xff) == (status & 0xff) && extended != SQLITE_OK + let code = matches ? extended : status + let reason = matches ? String(cString: sqlite3_errmsg(handle)) : String(cString: sqlite3_errstr(status)) + return .sqlite(operation: operation, code: code, reason: reason) + } + static func recoverRollbackJournal(at url: URL) throws { guard FileManager.default.fileExists(atPath: url.path + "-journal") else { return } // A process killed during SQLite promotion can leave a hot rollback diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index cb5c0ce54e7..24555d75bc6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -101,7 +101,12 @@ public enum DashModelContainer { Schema(versionedSchema: DashSchemaV2.self) } - /// Create a persistent model container for storing data + /// Create a persistent model container for storing data. + /// This synchronous call can copy and migrate a full database and block for + /// seconds. Do not call it on a UI actor. Apps with an explicit local URL + /// can use `createAsync(url:)` to open on the SDK's dedicated queue. + /// The legacy compatibility bridge is local-only; CloudKit uses the normal + /// migration plan because copying SQLite cannot preserve its sync state. /// - Parameters: /// - cloudKit: Whether to enable CloudKit sync (default: disabled) /// - groupContainer: App group container configuration @@ -123,7 +128,8 @@ public enum DashModelContainer { /// Open (or create) the store at an explicit file URL through the same /// schema and migration plan as `create(cloudKit:groupContainer:)`. The /// migration tests use it to open stores written by older builds exactly - /// the way the app would. + /// the way the app would. This synchronous call can block for seconds; + /// use `createAsync(url:)` on startup or from a UI actor. public static func create(url: URL) throws -> ModelContainer { let modelConfiguration = ModelConfiguration( schema: schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index d2c190c515c..967a589d768 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -137,6 +137,68 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testDiskPreflightIncludesWALAndRejectsBeforeCopying() throws { + try withStore { url in + let writer = try DashLegacyStoreSQLite.Connection(url, writable: true) + try writer.execute("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0") + try writer.execute("UPDATE ZPERSISTENTWALLET SET ZNAME='committed WAL data'") + let main = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber).int64Value + let wal = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: url.path + "-wal")[.size] as? NSNumber).int64Value + XCTAssertGreaterThan(wal, 0) + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + XCTAssertEqual(required, 4 * (main + wal) + max(64 * 1024 * 1024, (main + wal) / 2)) + let original = try DashLegacyStoreSQLite.rawDigest(url) + XCTAssertThrowsError(try open(url, hooks: .init( + visit: { _, _ in XCTFail("Insufficient space must reject before snapshotting") }, + availableCapacity: { directory in + XCTAssertEqual(directory, url.deletingLastPathComponent()) + return required - 1 + }))) { error in + guard case DashLegacyStoreSQLite.Failure.insufficientDiskSpace(let needed, let available) = error else { + return XCTFail("Expected an actionable storage error, got \(error)") + } + XCTAssertEqual(needed, required) + XCTAssertEqual(available, required - 1) + XCTAssertTrue(error.localizedDescription.contains("Free device storage and retry")) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(url), original) + XCTAssertFalse(FileManager.default.fileExists(atPath: DashLegacySchemaBridge.backupDirectory(for: url).path)) + withExtendedLifetime(writer) {} + } + try withStore(baseline: true) { url in + _ = try open(url, hooks: .init(availableCapacity: { _ in + XCTFail("Registered stores must not require bridge copy headroom") + return 0 + })) + } + } + + func testSufficientDiskHeadroomPermitsMigration() throws { + try withStore { url in + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + let container = try open(url, hooks: .init(availableCapacity: { _ in required })) + try verifyRows(container.mainContext) + } + } + + func testCopyReportsNonContentionSQLiteFailureWithoutReplacingDestination() throws { + try withStore { destination in + let source = destination.deletingLastPathComponent().appendingPathComponent("invalid.store") + try Data(repeating: 0x61, count: 4096).write(to: source) + let original = try DashLegacyStoreSQLite.rawDigest(destination) + XCTAssertThrowsError(try DashLegacyStoreSQLite.copy(from: source, to: destination)) { error in + guard case DashLegacyStoreSQLite.Failure.sqlite(let operation, let code, let reason) = error else { + return XCTFail("Expected an actual SQLite status, got \(error)") + } + XCTAssertEqual(code & 0xff, SQLITE_NOTADB) + XCTAssertFalse(reason.lowercased().contains("busy")) + XCTAssertTrue(error.localizedDescription.contains("SQLite \(code)")) + XCTAssertFalse(operation.isEmpty) + } + XCTAssertEqual(try DashLegacyStoreSQLite.rawDigest(destination), original) + } + } + func testHistoricalStorePreservesDataDefaultsBackupAndDoesNotBridgeAgain() throws { try withStore { url in try autoreleasepool { @@ -267,16 +329,33 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } - func testPendingRecoveryWithMissingOriginalNeverCreatesEmptyDatabase() throws { - try withStore { url in - XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in - if phase == .beforeInstall { throw Injected.stop } - }))) - let displaced = url.deletingLastPathComponent().appendingPathComponent("displaced.store") - try FileManager.default.moveItem(at: url, to: displaced) - XCTAssertThrowsError(try open(url)) - XCTAssertFalse(FileManager.default.fileExists(atPath: url.path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: displaced.path)) + func testPendingRecoveryWithMissingOriginalRequiresDeliberateRecovery() throws { + for removeBackup in [false, true] { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .beforeInstall { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let journal = try Data(contentsOf: marker) + let displaced = url.deletingLastPathComponent().appendingPathComponent("displaced.store") + try FileManager.default.moveItem(at: url, to: displaced) + if removeBackup { + for directory in try operationDirectories(url) { try FileManager.default.removeItem(at: directory) } + } + XCTAssertThrowsError(try open(url)) { error in + XCTAssertTrue(error.localizedDescription.contains("Restore the original database from a verified backup")) + XCTAssertTrue(error.localizedDescription.contains(root.path)) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: url.path), "Never resurrect a possibly intentional reset") + XCTAssertEqual(try Data(contentsOf: marker), journal, "Preserve recovery evidence even when copies are absent") + XCTAssertTrue(FileManager.default.fileExists(atPath: displaced.path)) + // Deliberate restoration of the authoritative source unblocks + // normal recovery; the bridge can recreate missing scratch files. + try FileManager.default.moveItem(at: displaced, to: url) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } } } From bf50e1a2a88e640d7ea0bcb7c3340a22037aec26 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 16:34:04 +0200 Subject: [PATCH 13/18] fix(swift-sdk): reclaim abandoned migration attempts under lock --- packages/swift-sdk/SCHEMA_RELEASES.md | 35 ++++- .../Persistence/DashLegacySchemaBridge.swift | 81 +++++++--- .../DashLegacySchemaMigrationTests.swift | 141 +++++++++++++++++- 3 files changed, 226 insertions(+), 31 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 7a1d6f5bfbc..34c06a4a3e4 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -31,9 +31,15 @@ Core Data migration that renumbers primary keys is rejected; such a layout requires an explicit, separately validated migration rather than relaxing this bridge's data-loss checks. -Before making full-size copies, the bridge checks free space on the store's -volume. Its conservative estimate is four times the combined main-file and WAL -size, plus the larger of 64 MiB or half that combined size. This budgets the two +Before making full-size copies, the bridge acquires its exclusive store lock +and removes abandoned UUID attempt directories only when no migration journal +is pending. This also recovers space after a process was killed before writing +its journal. It then measures actual free space on the store's volume; failed +deletions are never counted as available space. A missing, nonpositive or failing +ImportantUsage capacity reading falls back to filesystem free bytes; if both +report zero, migration remains blocked. Its conservative estimate is four times +the combined main-file and WAL size, plus the larger of 64 MiB or half that +combined size. This budgets the two copies, inferred-migration/promotion journals and schema/index growth. It is a preflight estimate, not a reservation: other processes or larger-than-estimated growth can still exhaust space, and SQLite errors remain fatal without replacing @@ -57,15 +63,22 @@ unknown CloudKit schemas require a separately supported migration. Recovery files live beside the original store under `.legacy-v2-backups/`. A successful bridge retains an `original.store` backup through that launch. A later successful ordinary open -reclaims completed backup directories; failed opens and pending migrations -never trigger cleanup. Cleanup failures do not prevent opening the wallet. +reclaims completed backup directories. That optional cleanup takes the same +nonblocking store lock and rechecks the journal; lock contention skips cleanup +without failing an ordinary open. Known stores without attempt directories do +not create a bridge lock file. Failed opens and pending migrations never trigger +cleanup. Cleanup failures do not prevent opening the wallet. The `active.json` journal records an interrupted installation and a fingerprint of the validated final data; the next open reconciles it before exposing a container, even if scratch copies were removed. Older journals still require their candidate when validating a committed installation. These are local wallet data, protected like the original store and excluded from device backup. Do not upload them as release fixtures or edit the recovery -journal to bypass a failure. +journal to bypass a failure. Clearing the journal is part of a successful open, +not optional cleanup: the factory has not yet returned the container to the app. +If clearing fails, the open fails and the next attempt validates recovery evidence +again. Ignoring that error could expose a writable container while leaving a +stale marker that rejects the app's legitimate later writes. A missing primary database with a pending journal requires deliberate recovery. The bridge never deletes or renames that primary file, so its absence is not a @@ -93,6 +106,16 @@ follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. +After publication, changing a runtime version's model shape in place can leave +existing App Store stores with no registered matching checksum and block startup. +`DashReleasedSchemaTests.testPublishedSnapshotsAndRuntimeVersionsMatchCapturedStores` +compares both the archival snapshot and the runtime version against the captured +published store; it fails on that drift. Keep the published version bound to its +snapshot, introduce a new live version and register the connecting migration. +The companion `testPublishedStoresMigrateAndRemainWritableThroughLiveTypes` then +checks that published stores still open and remain writable. No mutable live +fixture is needed for this publication boundary. + ## Release flow The iOS release workflow saves an immutable build manifest and content-addressed diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index d37e370b78d..59b6053d221 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -46,7 +46,7 @@ enum DashLegacySchemaBridge { // journal before the store can change to the destination schema. if !needsMigration && !FileManager.default.fileExists(atPath: marker.path) { let container = try ordinary() - reclaimCompletedBackups(at: root) + reclaimAfterSuccessfulOpen(at: url, root: root) return container } } @@ -73,6 +73,10 @@ enum DashLegacySchemaBridge { throw SQLite.Failure.unsupported("The old model contains unsupported or missing entities") } try rejectExternalStorage(at: url) + // A killed attempt may leave copies before it ever published a journal. + // Under the lock, no active marker means these directories are inactive. + // Remove them before measuring capacity; never credit hypothetical space. + reclaimInactiveAttempts(at: root, holding: lock) let requiredSpace = try requiredFreeSpace(at: url) let availableSpace = max(0, try hooks.availableCapacity(url.deletingLastPathComponent())) guard availableSpace >= requiredSpace else { @@ -153,8 +157,9 @@ enum DashLegacySchemaBridge { committed = true try hooks.visit(.afterCommit, url) let container = try ordinary() - // Clear before returning a live container. Recovery must never replace - // a successfully opened store after the app has started writing to it. + // This container has not escaped to the app, so no application writes + // are permitted yet. Clearing must succeed before returning it: a stale + // marker would incorrectly compare later writes with migration evidence. try clearJournal(at: root) try? FileManager.default.removeItem(at: candidate) return container @@ -188,13 +193,26 @@ enum DashLegacySchemaBridge { } private static func availableCapacity(at directory: URL) throws -> Int64 { - let values = try directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) - if let capacity = values.volumeAvailableCapacityForImportantUsage { return capacity } - let attributes = try FileManager.default.attributesOfFileSystem(forPath: directory.path) - guard let capacity = (attributes[.systemFreeSize] as? NSNumber)?.int64Value else { - throw SQLite.Failure.database("Cannot determine available storage; check device storage and retry") - } - return capacity + try resolveAvailableCapacity(importantUsage: { + try directory.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + .volumeAvailableCapacityForImportantUsage + }, fileSystem: { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: directory.path) + guard let capacity = (attributes[.systemFreeSize] as? NSNumber)?.int64Value else { + throw SQLite.Failure.database("Cannot determine available storage; check device storage and retry") + } + return capacity + }) + } + + /// Some macOS volumes report zero/negative or no ImportantUsage capacity + /// despite free filesystem space. Confirm those results with a real free-byte + /// query; never replace them with estimated savings or a test-only allowance. + static func resolveAvailableCapacity( + importantUsage: () throws -> Int64?, fileSystem: () throws -> Int64 + ) throws -> Int64 { + if let capacity = try? importantUsage(), capacity > 0 { return capacity } + return max(0, try fileSystem()) } private static func needsBridge(_ source: Identity, plan: any SchemaMigrationPlan.Type) throws -> Bool { @@ -205,18 +223,35 @@ enum DashLegacySchemaBridge { return true } - /// A backup survives the migration/recovery launch. Reclaim it only after - /// a later ordinary open succeeds and no migration journal is pending. - /// Cleanup failure affects disk usage, never the ability to open the wallet. - private static func reclaimCompletedBackups(at root: URL) { - let marker = root.appendingPathComponent("active.json") - guard !FileManager.default.fileExists(atPath: marker.path), - let entries = try? FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return } - for entry in entries where UUID(uuidString: entry.lastPathComponent) != nil { - guard let attributes = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), - attributes.isDirectory == true, attributes.isSymbolicLink != true else { continue } - try? FileManager.default.removeItem(at: entry) + /// Cleanup is optional after an ordinary open. Never make known/current + /// stores fail because a legacy opener holds the lock, and do not create a + /// lock file unless there are actual attempt directories to reclaim. + private static func reclaimAfterSuccessfulOpen(at url: URL, root: URL) { + guard !attemptDirectories(at: root).isEmpty, + let lock = try? StoreLock(url: url) else { return } + defer { lock.close() } + reclaimInactiveAttempts(at: root, holding: lock) + } + + /// Requires exclusive ownership for both checking the marker and deleting + /// copies. Call before a legacy attempt starts, or after a later ordinary + /// open; the successful migration/recovery launch retains its own backup. + private static func reclaimInactiveAttempts(at root: URL, holding _: StoreLock) { + guard !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) else { return } + for directory in attemptDirectories(at: root) { + // Failure only affects disk usage. Preflight measures the actual + // remaining free capacity after these attempts, not estimated savings. + try? FileManager.default.removeItem(at: directory) + } + } + + private static func attemptDirectories(at root: URL) -> [URL] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return [] } + return entries.filter { entry in + guard UUID(uuidString: entry.lastPathComponent) != nil, + let attributes = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return false } + return attributes.isDirectory == true && attributes.isSymbolicLink != true } } @@ -343,7 +378,7 @@ enum DashLegacySchemaBridge { guard flock(descriptor, LOCK_EX | LOCK_NB) == 0 else { Darwin.close(descriptor) descriptor = -1 - throw SQLite.Failure.database("Another process is opening this database; retry after it finishes") + throw SQLite.Failure.database("Another opener is using this database; retry after it finishes") } } func close() { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 967a589d768..ad192b61ff6 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -173,6 +173,21 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testCapacityResolverConfirmsUnavailableImportantUsageWithFilesystemFreeSpace() throws { + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 1024 }, fileSystem: { XCTFail("Positive capacity needs no fallback"); return 0 }), 1024) + for reported: Int64? in [0, -1, nil] { + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { reported }, fileSystem: { 2048 }), 2048) + } + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { throw Injected.stop }, fileSystem: { 4096 }), 4096) + XCTAssertEqual(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 0 }, fileSystem: { 0 }), 0, "A genuinely full volume remains full") + XCTAssertThrowsError(try DashLegacySchemaBridge.resolveAvailableCapacity( + importantUsage: { 0 }, fileSystem: { throw Injected.stop }), "Cannot invent capacity when both queries fail") + } + func testSufficientDiskHeadroomPermitsMigration() throws { try withStore { url in let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) @@ -199,6 +214,114 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testAbandonedAttemptsAreRemovedBeforeDiskCapacityIsMeasured() throws { + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + try Data(repeating: 0x61, count: 4096).write(to: abandoned.appendingPathComponent("original.store")) + try Data(repeating: 0x62, count: 4096).write(to: abandoned.appendingPathComponent("candidate.store")) + let unrelated = root.appendingPathComponent("operator-note.txt") + try Data("keep".utf8).write(to: unrelated) + let required = try DashLegacySchemaBridge.requiredFreeSpace(at: url) + let container = try open(url, hooks: .init(availableCapacity: { _ in + XCTAssertFalse(FileManager.default.fileExists(atPath: abandoned.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: unrelated.path)) + return required + })) + try verifyRows(container.mainContext) + XCTAssertEqual(try operationDirectories(url).count, 1, "Keep the new migration backup through this open") + } + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + // A failed removal must not be counted as reclaimed free space. + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try open(url, hooks: .init(availableCapacity: { _ in + XCTAssertTrue(FileManager.default.fileExists(atPath: abandoned.path)) + return 0 + }))) { error in + guard case DashLegacyStoreSQLite.Failure.insufficientDiskSpace(_, let available) = error else { + return XCTFail("Expected actual available capacity to decide admission: \(error)") + } + XCTAssertEqual(available, 0) + } + } + } + + func testOrdinaryOpenSkipsCleanupWhileAnotherOpenerOwnsTheLock() throws { + try withStore(baseline: true) { url in + try autoreleasepool { _ = try open(url) } + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let active = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: active, withIntermediateDirectories: true) + let copy = active.appendingPathComponent("original.store") + try Data("active snapshot".utf8).write(to: copy) + let descriptor = Darwin.open(url.path + ".legacy-v2.lock", O_CREAT | O_RDWR, 0o600) + XCTAssertGreaterThanOrEqual(descriptor, 0) + defer { Darwin.close(descriptor) } + XCTAssertEqual(flock(descriptor, LOCK_EX | LOCK_NB), 0) + try autoreleasepool { + let container = try open(url) + XCTAssertEqual(try container.mainContext.fetchCount(FetchDescriptor()), 1) + } + XCTAssertEqual(try Data(contentsOf: copy), Data("active snapshot".utf8)) + XCTAssertEqual(flock(descriptor, LOCK_UN), 0) + _ = try open(url) + XCTAssertFalse(FileManager.default.fileExists(atPath: active.path), "A later unlocked successful open may reclaim it") + } + } + + func testPendingRecoveryProtectsAllAttemptsAndRetainsRecoveredBackup() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let originalJournal = try Data(contentsOf: marker) + let abandoned = root.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: abandoned, withIntermediateDirectories: true) + let attempts = Set(try operationDirectories(url)) + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='unverified change'") + XCTAssertThrowsError(try open(url)) + XCTAssertEqual(Set(try operationDirectories(url)), attempts) + XCTAssertEqual(try Data(contentsOf: marker), originalJournal) + try DashLegacyStoreSQLite.Connection(url, writable: true) + .execute("UPDATE ZPERSISTENTWALLET SET ZNAME='historical audit wallet'") + try autoreleasepool { + let recovered = try open(url) + try verifyRows(recovered.mainContext) + } + XCTAssertEqual(Set(try operationDirectories(url)), attempts, "The successful recovery open retains its backup") + let reopened = try open(url) + try verifyRows(reopened.mainContext) + XCTAssertTrue(try operationDirectories(url).isEmpty) + } + } + + func testJournalRemovalFailureDoesNotExposeAMigratedContainer() throws { + try withStore { url in + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + } + }))) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + XCTAssertEqual(try operationDirectories(url).count, 1) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + let recovered = try open(url) + try verifyRows(recovered.mainContext) + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + } + func testHistoricalStorePreservesDataDefaultsBackupAndDoesNotBridgeAgain() throws { try withStore { url in try autoreleasepool { @@ -527,7 +650,14 @@ final class DashLegacySchemaMigrationTests: XCTestCase { var longestGap = 0.0 var previous = Date.timeIntervalSinceReferenceDate while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 10_000_000) + // Nonthrowing tick: cancellation must not produce a secondary + // CancellationError while reporting an actual migration failure. + await withCheckedContinuation { continuation in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(10)) { + continuation.resume() + } + } + guard !Task.isCancelled else { break } let now = Date.timeIntervalSinceReferenceDate longestGap = max(longestGap, now - previous) previous = now @@ -537,7 +667,14 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } defer { heartbeat.cancel() } let started = Date.timeIntervalSinceReferenceDate - let container = try await DashModelContainer.createAsync(url: url) + let container: ModelContainer + do { + container = try await DashModelContainer.createAsync(url: url) + } catch { + heartbeat.cancel() + _ = await heartbeat.value + throw error // Preserve the migration failure after draining the tick. + } let elapsed = Date.timeIntervalSinceReferenceDate - started heartbeat.cancel() let (ticks, longestGap) = await heartbeat.value From 77f8c2998d4fd86a800b7288f678ebca564357b5 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Sun, 20 Sep 2026 16:34:21 +0200 Subject: [PATCH 14/18] fix(swift-sdk): order freeze pull requests by latest attempt --- .../swift-sdk/scripts/freeze_appstore_release.py | 5 ++++- .../scripts/test_freeze_appstore_release.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index 38b35b08484..476838fd50b 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -224,7 +224,8 @@ def pull_requests(self, branch): page = 1 while True: query = urllib.parse.urlencode({"head": f"dashpay:{branch}", "base": BASE_BRANCH, - "state": "all", "per_page": 100, "page": page}) + "state": "all", "sort": "created", "direction": "desc", + "per_page": 100, "page": page}) rows = self.request("GET", f"pulls?{query}") results.extend(rows) if len(rows) < 100: @@ -309,6 +310,8 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): opened = [pr for pr in pulls if pr["state"] == "open"] if len(opened) > 1: raise ReleaseError("Multiple open snapshot pull requests need manual reconciliation") + # The newest closed attempt records the maintainer's latest decision. + # An older merged PR must not override a subsequently rejected follow-up. if not opened and pulls and not pulls[0].get("merged_at"): raise ReleaseError("The snapshot pull request was closed without merging; reopen it to retry") env = git_environment(token) diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index ec698a27b72..6d7b7e7875f 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -222,6 +222,8 @@ def test_pr_listing_is_paginated(self): api.request = mock.Mock(side_effect=[[{}] * 100, [{"number": 101}]]) self.assertEqual(len(api.pull_requests("codex/freeze-swift-schema-v2.0.0")), 101) self.assertIn("page=2", api.request.call_args.args[1]) + self.assertIn("sort=created", api.request.call_args.args[1]) + self.assertIn("direction=desc", api.request.call_args.args[1]) class WorkerIntegrationTests(unittest.TestCase): @@ -308,6 +310,17 @@ def test_fixture_check_closes_connection_on_success_or_failure(self): with self.assertRaises(sqlite3.ProgrammingError): database.execute("SELECT 1") + def test_newest_closed_unmerged_attempt_is_not_overridden_by_an_older_merge(self): + self.api.pull_requests.return_value = [ + {"state": "closed", "merged_at": None}, + {"state": "closed", "merged_at": "2026-09-18"}, + ] + before = git(self.remote, "show-ref") + with self.assertRaisesRegex(worker.ReleaseError, "reopen it to retry"): + self.prepare() + self.assertEqual(git(self.remote, "show-ref"), before) + self.api.request.assert_not_called() + def test_push_then_retry_reuses_draft_pr_and_commit(self): self.prepare() self.assertTrue(self.api.request.call_args.args[2]["draft"]) From 41642ca022228e7bc1ce223191c26e7f259be7ee Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 08:37:36 +0200 Subject: [PATCH 15/18] fix(swift-sdk): validate stored value dependencies before freezing --- .../swift-sdk/scripts/freeze_schema_models.py | 300 +++++++++++++++++- .../scripts/test_freeze_schema_models.py | 124 +++++++- 2 files changed, 411 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index 02a01b7c657..493fa2d4e33 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -12,7 +12,10 @@ change moves it onto the snapshot and introduces a new live version/migration. --check verifies deterministic generated sources, registry bindings and immutable -fixture digests. It cannot prove hash-relevant graph completeness: the runtime +fixture digests. --check-inventory rejects missing transitive stored value types +within the supported explicit storage grammar. Standard Swift/Foundation type +names must not be shadowed elsewhere in the SDK. Custom Codable methods and +other helper behavior remain outside this declaration check. The runtime DashReleasedSchemaTests compares SwiftData's hashes and SQLite indexes against the captured fixture, after constructing the live schema first. This also catches inline value types or relationship references accidentally left live. @@ -371,7 +374,10 @@ def validate_inventory(inventory): raise SystemExit("unsupported historical schema model inventory") models = inventory["models"] groups = inventory["value_types"] - for name in list(models) + [name for group in groups for name in group["names"]]: + names = list(models) + [name for group in groups for name in group["names"]] + if len(names) != len(set(names)): + raise SystemExit("duplicate type in model inventory") + for name in names: if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", name): raise SystemExit("invalid Swift name in model inventory") for path in list(models.values()) + [group["path"] for group in groups]: @@ -380,6 +386,265 @@ def validate_inventory(inventory): return inventory +# This is deliberately a restricted declaration grammar, not a Swift compiler. +# It checks explicit stored type expressions against the copied graph and +# standard types; standard names must not be shadowed elsewhere in the SDK. +# Unsupported storage syntax fails closed; SwiftData's native hash/index tests +# still verify the generated models' semantics and registration order. +SWIFT_SCALARS = set("Bool String Character Int Int8 Int16 Int32 Int64 UInt UInt8 UInt16 UInt32 UInt64 Float Double".split()) +FOUNDATION_SCALARS = set("Data Date UUID Decimal URL TimeInterval".split()) +STORAGE_CONTAINERS = {"Array": 1, "Set": 1, "Optional": 1, "Dictionary": 2} +STORAGE_PROTOCOLS = set("Codable Decodable Encodable Equatable Hashable Sendable CaseIterable Identifiable".split()) + + +def storage_tokens(lines, owner): + tokens = [] + for line in lines: + # The existing source copier supports ordinary single-line strings. + # Do not guess where a raw/multiline string or backtick identifier ends. + if re.search(r'#+"|"""|`', line.split("//", 1)[0]): + raise SystemExit(f"{owner}: raw/multiline strings and escaped identifiers need explicit parser support") + code = code_only(line) + tokens.extend(re.findall(r'[A-Za-z_][A-Za-z_0-9]*|""|[^\s]', code)) + tokens.append("\n") + return tokens + + +def matching_token(tokens, start, owner): + opening = tokens[start] + closing = {"(": ")", "[": "]", "{": "}"}[opening] + index = start + 1 + while index < len(tokens): + if tokens[index] == closing: + return index + if tokens[index] in ("(", "[", "{"): + index = matching_token(tokens, index, owner) + elif tokens[index] in (")", "]", "}"): + break + index += 1 + raise SystemExit(f"{owner}: unsupported or unbalanced storage declaration") + + +def validate_stored_type(tokens, names, owner): + tokens = [token for token in tokens if token != "\n"] + index = 0 + + def consume(): + nonlocal index + if index == len(tokens): + raise SystemExit(f"{owner}: missing stored type") + token = tokens[index] + index += 1 + if token == "[": + consume() + if index < len(tokens) and tokens[index] == ":": + index += 1 + consume() + if index == len(tokens) or tokens[index] != "]": + raise SystemExit(f"{owner}: unsupported collection type") + index += 1 + elif re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", token): + name = token + if index < len(tokens) and tokens[index] == ".": + index += 1 + if index == len(tokens): + raise SystemExit(f"{owner}: incomplete qualified type") + name = tokens[index] + index += 1 + permitted = SWIFT_SCALARS | set(STORAGE_CONTAINERS) if token == "Swift" else FOUNDATION_SCALARS if token == "Foundation" else set() + if name not in permitted: + raise SystemExit(f"{owner}: module-qualified or nested stored type {token}.{name} is not isolated") + if name in STORAGE_CONTAINERS: + if index == len(tokens) or tokens[index] != "<": + raise SystemExit(f"{owner}: collection requires explicit type arguments") + index += 1 + for argument in range(STORAGE_CONTAINERS[name]): + if argument: + if index == len(tokens) or tokens[index] != ",": + raise SystemExit(f"{owner}: invalid generic collection arguments") + index += 1 + consume() + if index == len(tokens) or tokens[index] != ">": + raise SystemExit(f"{owner}: invalid generic collection arguments") + index += 1 + elif name not in names | SWIFT_SCALARS | FOUNDATION_SCALARS: + raise SystemExit(f"{owner}: stored type {name} is absent from schema-models.json; include its transitive value graph") + else: + raise SystemExit(f"{owner}: unsupported stored type expression {' '.join(tokens)}") + while index < len(tokens) and tokens[index] == "?": + index += 1 + + consume() + if index != len(tokens): + raise SystemExit(f"{owner}: unsupported stored type expression {' '.join(tokens)}") + + +def validate_storage_declaration(lines, name, names): + tokens = storage_tokens(lines, name) + start = tokens.index("{") + header = [token for token in tokens[:start] if token != "\n"] + if any(header[index + 1] != "Model" for index, token in enumerate(header) if token == "@"): + raise SystemExit(f"{name}: unsupported type declaration macro") + kind = next(token for token in header if token in ("class", "struct", "enum")) + inherited = header[header.index(name) + 1:] + if inherited: + if inherited[0] != ":" or any(token not in STORAGE_PROTOCOLS | SWIFT_SCALARS | {":", ","} for token in inherited): + raise SystemExit(f"{name}: generic types, custom conformances and inherited storage need explicit parser support") + end = matching_token(tokens, start, name) + index = start + 1 + prefix = [] + attributes = [] + while index < end: + token = tokens[index] + if token in ("\n", ";"): + index += 1 + continue + if token == "@": + attribute = tokens[index + 1] + index += 2 + if index < end and tokens[index] == "(": + close = matching_token(tokens, index, name) + arguments = tokens[index + 1:close] + if attribute == "Attribute" and "transformable" in arguments: + raise SystemExit(f"{name}: transformable storage requires explicit isolation support") + index = close + 1 + if attribute not in ("Attribute", "Relationship", "Transient"): + raise SystemExit(f"{name}: unsupported property macro @{attribute}") + attributes.append(attribute) + continue + if token in ("#", "typealias", "associatedtype"): + if token == "#" and tokens[index + 1] in ("Index", "Unique"): + opening = index + 2 + if tokens[opening:opening + 3] == ["<", name, ">"]: + opening += 3 + if tokens[opening] != "(": + raise SystemExit(f"{name}: unsupported index/unique declaration") + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + raise SystemExit(f"{name}: conditional declarations, aliases and declaration macros need explicit isolation support") + if token == "class" and tokens[index + 1] in ("var", "func"): + prefix.append(token) + index += 1 + continue + if token in ("struct", "class", "enum", "actor", "protocol"): + nested_name = tokens[index + 1] + if nested_name in names | SWIFT_SCALARS | FOUNDATION_SCALARS | set(STORAGE_CONTAINERS) | {"Swift", "Foundation"}: + raise SystemExit(f"{name}: nested type {nested_name} shadows a stored type") + # Unused nested helpers are copied with their enclosing type. Any + # stored reference to them is rejected by validate_stored_type. + opening = tokens.index("{", index) + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + if token in ("var", "let"): + field = tokens[index + 1] + if not re.fullmatch(r"[A-Za-z_][A-Za-z_0-9]*", field): + raise SystemExit(f"{name}: destructured stored declarations are unsupported") + cursor = index + 2 + expression = [] + while cursor < end and tokens[cursor] not in ("=", "{", ";", "\n"): + if tokens[cursor] in ("[", "("): + close = matching_token(tokens, cursor, name) + expression.extend(tokens[cursor:close + 1]) + cursor = close + 1 + else: + expression.append(tokens[cursor]) + cursor += 1 + # An accessor body is computed, except willSet/didSet observers. + # A closure initializer follows '=' and is still stored. + next_code = cursor + while tokens[next_code] == "\n": + next_code += 1 + computed = tokens[next_code] == "{" + if computed: + close = matching_token(tokens, next_code, name) + body = tokens[next_code + 1:close] + computed = "willSet" not in body and "didSet" not in body + if not (computed or "static" in prefix or "class" in prefix or "Transient" in attributes): + if not expression or expression[0] != ":": + raise SystemExit(f"{name}.{field}: inferred stored types need an explicit type annotation") + validate_stored_type(expression[1:], names, f"{name}.{field}") + # Skip the initializer/accessors, not just the type. A comma at + # declaration level could start another binding and is refused. + index = cursor + while index < end and tokens[index] not in ("\n", ";"): + if tokens[index] in ("{", "(", "["): + index = matching_token(tokens, index, name) + 1 + elif tokens[index] == ",": + raise SystemExit(f"{name}.{field}: multiple property bindings need separate declarations") + else: + index += 1 + if next_code < end and tokens[next_code] == "{" and cursor != next_code: + index = matching_token(tokens, next_code, name) + 1 + prefix, attributes = [], [] + continue + if token == "case" and kind == "enum": + index += 1 + while index < end and tokens[index] not in ("\n", ";"): + if tokens[index] == "(": + close = matching_token(tokens, index, name) + payload = tokens[index + 1:close] + # Split only outer commas; generic dictionary arguments + # and nested bracket syntax belong to the same payload. + groups, group, depth = [], [], 0 + for part in payload + [","]: + if part == "," and depth == 0: + groups.append(group) + group = [] + else: + group.append(part) + depth += int(part in ("[", "<", "(")) - int(part in ("]", ">", ")")) + for group in groups: + if ":" in group and group.index(":") < 2: + group = group[group.index(":") + 1:] + validate_stored_type(group, names, f"{name} enum payload") + index = close + 1 + else: + index += 1 + prefix, attributes = [], [] + continue + if token in ("func", "init", "deinit", "subscript"): + opening = index + 1 + while opening < end and tokens[opening] != "{": + if tokens[opening] in ("(", "["): + opening = matching_token(tokens, opening, name) + opening += 1 + if opening == end: + raise SystemExit(f"{name}: declaration without a body needs explicit parser support") + index = matching_token(tokens, opening, name) + 1 + prefix, attributes = [], [] + continue + if token == "(": + if tokens[index:index + 3] != ["(", "set", ")"]: + raise SystemExit(f"{name}: unsupported declaration continuation") + index = matching_token(tokens, index, name) + 1 + continue + if token not in {"public", "private", "fileprivate", "internal", "package", "open", "static", + "final", "override", "required", "convenience", "mutating", "nonmutating", + "lazy", "weak", "unowned", "dynamic", "nonisolated"}: + raise SystemExit(f"{name}: unsupported declaration token {token!r}") + prefix.append(token) + index += 1 + + +def validate_storage_graph(inventory, sources): + names = set(inventory["models"]) | {name for group in inventory["value_types"] for name in group["names"]} + if names & (SWIFT_SCALARS | FOUNDATION_SCALARS | set(STORAGE_CONTAINERS) | {"Swift", "Foundation"}): + raise SystemExit("inventory shadows a standard stored type") + for name, path in inventory["models"].items(): + validate_storage_declaration(extract_class(sources[path], name), name, names) + for group in inventory["value_types"]: + for name in group["names"]: + validate_storage_declaration(extract_value_type(sources[group["path"]], name), name, names) + + +def inventory_sources(root, inventory, commit=None): + paths = set(inventory["models"].values()) | {group["path"] for group in inventory["value_types"]} + return {path: git(root, "show", f"{commit}:{path}") if commit else pathlib.Path(root, path).read_text(encoding="utf-8") + for path in sorted(paths)} + + def render_snapshot(root, version, entry, *, inventory=None): validate_schema(entry["schema"]) if entry["schema"]["schema_version"] != version: @@ -394,19 +659,21 @@ def render_snapshot(root, version, entry, *, inventory=None): models = inventory["models"] if set(models) != set(entry["schema"]["entity_hashes"]): raise SystemExit("historical inventory differs from captured model membership") + sources = inventory_sources(root, inventory, commit) + validate_storage_graph(inventory, sources) names = set(models) | {name for group in inventory["value_types"] for name in group["names"]} sibling = re.compile(r"(?>]\n}\n" + "public enum Event: Codable {\n case changed(detail: [String: Leaf?]), deleted\n}\n" + "public struct Leaf: Codable {\n var values: Swift.Set\n}\n") + self.include_values(["Payload", "Event"], source) + with self.assertRaisesRegex(SystemExit, "Event enum payload: stored type Leaf is absent"): + self.check() + self.inventory["value_types"][0]["names"].append("Leaf") + self.check() + self.sources[self.values_path] = source.replace("Swift.Set", "Dictionary") + with self.assertRaisesRegex(SystemExit, "Leaf.values: stored type Missing is absent"): + self.check() + + def test_should_ignore_computed_static_and_transient_helpers_but_not_observed_storage(self): + self.sources[self.model_path] = """@Model +public final class PersistentThing { + @Transient var helper: LiveHelper? + static var cache = LiveHelper() + var computed: LiveHelper { LiveHelper() } + var nextLine: LiveHelper + { LiveHelper() } + var stored: String = "var phantom: Missing { // }" + // var omitted: Missing + func helper(defaultValue: () -> LiveHelper = { LiveHelper() }) { } +} +""" + self.check() + self.sources[self.model_path] = self.sources[self.model_path].replace( + 'var stored: String = "var phantom: Missing { // }"', + "var stored: Missing { didSet { print(stored) } }") + with self.assertRaisesRegex(SystemExit, "PersistentThing.stored: stored type Missing is absent"): + self.check() + + def test_should_fail_closed_for_unsupported_storage_forms(self): + forms = { + "var payload = Payload()": "inferred stored types", + "var payload: External.Payload?": "not isolated", + "typealias Alias = Payload\nvar payload: Alias": "aliases", + "var payload: Alias": "stored type Alias is absent", + "var payload: (String, Int)": "unsupported stored type", + "var payload: Wrapper": "stored type Wrapper is absent", + "var a: String, b: Missing": "unsupported stored type", + "#if DEBUG\nvar payload: String\n#endif": "conditional declarations", + "@Attribute(.transformable(by: LiveTransformer.self)) var payload: Data": "transformable", + "@MyStorage var payload: String": "unsupported property macro", + "enum Swift { }\nvar payload: Swift.String": "shadows a stored type", + } + for declaration, error in forms.items(): + self.sources[self.model_path] = "@Model\npublic final class PersistentThing {\n" + declaration + "\n}\n" + with self.subTest(declaration=declaration), self.assertRaisesRegex(SystemExit, error): + self.check() + + def test_should_refuse_to_guess_unsupported_lexical_syntax(self): + for declaration, error in (("var `payload`: String", "escaped identifiers"), + ('var payload: String = #"raw"#', "raw/multiline"), + ("/* hidden } */ var payload: Missing", "block comments")): + self.sources[self.model_path] = "@Model\npublic final class PersistentThing {\n" + declaration + "\n}\n" + with self.subTest(declaration=declaration), self.assertRaisesRegex(SystemExit, error): + self.check() + + def test_should_check_the_current_inventory_and_detect_a_real_transitive_omission(self): + inventory = gen.validate_inventory(json.loads(Path(ROOT, gen.INVENTORY_FILE).read_text())) + sources = gen.inventory_sources(ROOT, inventory) + gen.validate_storage_graph(inventory, sources) + inventory["value_types"][0]["names"].remove("DistributionEvent") + with self.assertRaisesRegex(SystemExit, "TokenPreProgrammedDistribution.distributionSchedule: stored type DistributionEvent is absent"): + gen.validate_storage_graph(inventory, sources) + + def test_should_reject_omitted_values_before_rendering_a_snapshot(self): + schema = {"schema_version": "2.0.0", "model_checksum": "checksum", "entity_hashes": {"PersistentThing": "ab"}, "indexes": []} + entry = {"platform_sha": "a" * 40, "namespace": "DashSchemaSnapshotV2", "schema": schema} + def read(root, operation, object_path): + self.assertEqual(operation, "show") + path = object_path.split(":", 1)[1] + return json.dumps(self.inventory) if path == gen.INVENTORY_FILE else self.sources[path] + with mock.patch.object(gen, "git", side_effect=read): + with self.assertRaisesRegex(SystemExit, "stored type Payload is absent"): + gen.render_snapshot(ROOT, "2.0.0", entry) + + class ReleaseTests(unittest.TestCase): def setUp(self): self.root = tempfile.mkdtemp() @@ -120,9 +221,9 @@ def setUp(self): def git(root, *args): self.git_calls.append(args) - if args == ("show", "a" * 40 + ":" + gen.INVENTORY_FILE): + if args[0] == "show" and args[1].split(":", 1)[1] == gen.INVENTORY_FILE: return json.dumps(self.inventory) - if args == ("show", "a" * 40 + ":" + gen.MODELS_DIR + "/PersistentThing.swift"): + if args[0] == "show" and args[1].split(":", 1)[1] == gen.MODELS_DIR + "/PersistentThing.swift": return "@Model\npublic final class PersistentThing {\n var old: String = \"released\"\n}\n" raise AssertionError(f"unexpected historical read: {args}") @@ -161,6 +262,25 @@ def test_should_reuse_same_shape_even_when_a_later_build_has_different_bytes_and "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} self.assertEqual(gen.add_release(self.root, next_manifest, self.fixture), first) + def test_should_reject_missing_value_types_before_admitting_new_or_same_shape_evidence(self): + original_read = gen.git + + def omitted_value(root, *args): + if args[0] == "show" and args[1].endswith("/PersistentThing.swift"): + return "@Model\npublic final class PersistentThing {\n var payload: MissingCodable?\n}\n" + return original_read(root, *args) + + for already_registered in (False, True): + if already_registered: + gen.add_release(self.root, self.manifest, self.fixture) + before = Path(self.root, gen.REGISTRY_FILE).read_bytes() + with self.subTest(already_registered=already_registered), mock.patch.object(gen, "git", side_effect=omitted_value): + with self.assertRaisesRegex(SystemExit, "stored type MissingCodable is absent"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(Path(self.root, gen.REGISTRY_FILE).read_bytes(), before) + if not already_registered: + self.assertFalse(Path(self.root, gen.FIXTURE_DIR).exists()) + def test_should_reject_changed_shape_under_published_version_including_indexes(self): gen.add_release(self.root, self.manifest, self.fixture) original = self.fixture.read_bytes() From f882160e962573460f24065d6aa7816bcf15a635 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 08:37:37 +0200 Subject: [PATCH 16/18] fix(swift-sdk): reconcile merged releases before closed pull requests --- .../scripts/freeze_appstore_release.py | 9 +++--- .../scripts/test_freeze_appstore_release.py | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/swift-sdk/scripts/freeze_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py index 476838fd50b..f3c329f7701 100644 --- a/packages/swift-sdk/scripts/freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -310,10 +310,6 @@ def prepare(repo, data_repo, release_id, data_commit, token, dry_run=False): opened = [pr for pr in pulls if pr["state"] == "open"] if len(opened) > 1: raise ReleaseError("Multiple open snapshot pull requests need manual reconciliation") - # The newest closed attempt records the maintainer's latest decision. - # An older merged PR must not override a subsequently rejected follow-up. - if not opened and pulls and not pulls[0].get("merged_at"): - raise ReleaseError("The snapshot pull request was closed without merging; reopen it to retry") env = git_environment(token) with tempfile.TemporaryDirectory(prefix="appstore-schema-") as temporary: clone = Path(temporary) / "platform" @@ -344,6 +340,11 @@ def generate(*args): retain_source(clone, commit, env, dry_run=dry_run) print("This release is already present in the merged registry.") return + # The branch is shared by releases with the same schema. A rejected + # follow-up must block an unregistered release, not invalidate one + # already verified in the merged registry above. + if not opened and pulls and not pulls[0].get("merged_at"): + raise ReleaseError("The snapshot pull request was closed without merging; reopen it to retry") remote_branch = git(clone, "ls-remote", "--heads", "origin", branch, env=env) if remote_branch: git(clone, "fetch", "origin", f"{branch}:refs/remotes/origin/{branch}", env=env) diff --git a/packages/swift-sdk/scripts/test_freeze_appstore_release.py b/packages/swift-sdk/scripts/test_freeze_appstore_release.py index 6d7b7e7875f..3b317cb35c5 100644 --- a/packages/swift-sdk/scripts/test_freeze_appstore_release.py +++ b/packages/swift-sdk/scripts/test_freeze_appstore_release.py @@ -316,8 +316,9 @@ def test_newest_closed_unmerged_attempt_is_not_overridden_by_an_older_merge(self {"state": "closed", "merged_at": "2026-09-18"}, ] before = git(self.remote, "show-ref") - with self.assertRaisesRegex(worker.ReleaseError, "reopen it to retry"): - self.prepare() + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "reopen it to retry"): + self.prepare(dry_run=dry_run) self.assertEqual(git(self.remote, "show-ref"), before) self.api.request.assert_not_called() @@ -525,6 +526,31 @@ def test_already_merged_release_checks_source_tags_without_creating_missing_tags self.assertEqual(git(self.remote, "show-ref"), before) self.api.request.assert_not_called() + def test_handled_release_survives_a_later_rejected_pr_and_still_repairs_source_tags(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + merged_commit = git(self.remote, "rev-parse", branch) + git(self.remote, "update-ref", f"refs/heads/{worker.BASE_BRANCH}", merged_commit) + self.api.pull_requests.return_value = [ + {"state": "closed", "merged_at": None}, + {"state": "closed", "merged_at": "2026-09-18"}, + ] + self.api.request.reset_mock() + source = self.manifest["platform_sha"] + ref = worker.SOURCE_TAG_PREFIX + source + git(self.remote, "update-ref", "-d", ref) + before = git(self.remote, "show-ref") + self.prepare(dry_run=True) + self.assertEqual(git(self.remote, "show-ref"), before) + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", ref), source) + self.assertEqual(git(self.remote, "rev-parse", branch), merged_commit) + git(self.remote, "update-ref", ref, merged_commit) + for dry_run in (True, False): + with self.subTest(dry_run=dry_run), self.assertRaisesRegex(worker.ReleaseError, "different object"): + self.prepare(dry_run=dry_run) + self.api.request.assert_not_called() + def test_new_release_of_same_schema_appends_association_to_existing_pr(self): self.prepare() branch = "codex/freeze-swift-schema-v2.0.0" From 209dff284ab0dec52219561e659c32d15c2ea5b6 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 08:37:37 +0200 Subject: [PATCH 17/18] fix(swift-sdk): discard completed migration snapshots during wallet deletion --- packages/swift-sdk/SCHEMA_RELEASES.md | 27 ++++- .../Persistence/DashLegacySchemaBridge.swift | 45 ++++++-- .../PlatformWalletManager.swift | 3 + .../PlatformWalletPersistenceHandler.swift | 22 +++- .../DashLegacySchemaMigrationTests.swift | 103 ++++++++++++++++++ 5 files changed, 189 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 34c06a4a3e4..2c4e67b1aff 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -68,6 +68,16 @@ nonblocking store lock and rechecks the journal; lock contention skips cleanup without failing an ordinary open. Known stores without attempt directories do not create a bridge lock file. Failed opens and pending migrations never trigger cleanup. Cleanup failures do not prevent opening the wallet. +Explicit wallet deletion has a stricter contract: +`PlatformWalletPersistenceHandler.deleteCompletedMigrationSnapshots()` removes +completed copies under the same store lock and propagates cleanup errors. +`deleteWalletData` and the manager's wallet deletion invoke it before deleting +SDK keys or live rows. Call it for a full-store wipe even when no wallet rows +remain, and do so for every affected network store. Pending recovery is preserved +and blocks deletion. Because a snapshot contains the whole historical database, +removing one wallet discards the whole completed snapshot, while unrelated live +wallets and other stores remain untouched. A cached container does not bypass +this deletion boundary. This is separate from best-effort startup cleanup. The `active.json` journal records an interrupted installation and a fingerprint of the validated final data; the next open reconciles it before exposing a container, even if scratch copies were removed. Older journals still require @@ -192,6 +202,17 @@ branch-only push workflows and do not trigger product release publishing. ## Developing the next schema +Keep `schema-models.json` complete for models and their stored value types. +Run `freeze_schema_models.py --check-inventory` before capture; the iOS capture +workflow does this automatically, and snapshot rendering validates the historical +source inventory again. Validation follows explicit fields and enum payloads, +including nested optional/array/set/dictionary values. Missing user-defined +types and unsupported storage declarations fail instead of silently binding to +live definitions. The validator uses a restricted Swift declaration grammar; +standard Swift/Foundation names are assumed not to be shadowed by application +types. It does not prove custom encoding or helper-method behavior, so native +captured-store hash/index checks and code review remain required. + A released snapshot has its own namespace, for example `DashSchemaSnapshotV2`. It is not automatically registered alongside identical current models. When changing the structure after a release, explicitly register the historical @@ -217,7 +238,10 @@ and never contain user wallet material. The worker merges current development into the branch, preserves human edits, and pushes without force. Resolve merge conflicts manually before retrying. - A push that succeeds before PR creation fails is recovered on the next run. - If the PR was closed without merge, reopen it deliberately before retrying. + If an unregistered release's PR was closed without merge, reopen it deliberately + before retrying. Already-registered releases are validated against the merged + registry first; a later rejected association for another release on the shared + schema branch does not prevent their source-tag validation or repair. - A missing build record, changed digest, conflicting schema number or rewritten release association stops processing. Restore the correct original record through the release-data recovery process; never guess a source SHA. @@ -236,6 +260,7 @@ Run the automation tests with: ```sh python3 -m unittest discover -s packages/swift-sdk/scripts -p 'test_*.py' python3 packages/swift-sdk/scripts/freeze_schema_models.py --check +python3 packages/swift-sdk/scripts/freeze_schema_models.py --check-inventory python3 packages/swift-sdk/scripts/historical_schema_fixture.py --check ``` diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift index 59b6053d221..7fd33cb0e58 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -223,11 +223,35 @@ enum DashLegacySchemaBridge { return true } + /// Privacy boundary for explicit wallet deletion, including cached stores. + /// A snapshot contains the whole old database, so deleting one wallet must + /// discard the completed snapshots rather than edit their historical graph. + /// This never touches live rows, another store's copies, or a pending journal. + static func deleteCompletedSnapshots(at url: URL) throws { + let root = backupDirectory(for: url) + guard FileManager.default.fileExists(atPath: root.path) else { return } + let lock = try StoreLock(url: url) + defer { lock.close() } + guard !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) else { + throw SQLite.Failure.unsupported("Wallet deletion cannot discard pending migration recovery. Reopen the store successfully before deleting wallets.") + } + let attributes = try root.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isDirectory == true, attributes.isSymbolicLink != true else { + throw SQLite.Failure.unsupported("The migration snapshot directory is not a local directory") + } + for directory in try attemptDirectories(at: root) { + // Unlike opportunistic startup cleanup, explicit deletion must + // report any failure rather than leave a separately readable copy. + try FileManager.default.removeItem(at: directory) + } + try synchronizeDirectory(root) + } + /// Cleanup is optional after an ordinary open. Never make known/current /// stores fail because a legacy opener holds the lock, and do not create a /// lock file unless there are actual attempt directories to reclaim. private static func reclaimAfterSuccessfulOpen(at url: URL, root: URL) { - guard !attemptDirectories(at: root).isEmpty, + guard let directories = try? attemptDirectories(at: root), !directories.isEmpty, let lock = try? StoreLock(url: url) else { return } defer { lock.close() } reclaimInactiveAttempts(at: root, holding: lock) @@ -238,20 +262,23 @@ enum DashLegacySchemaBridge { /// open; the successful migration/recovery launch retains its own backup. private static func reclaimInactiveAttempts(at root: URL, holding _: StoreLock) { guard !FileManager.default.fileExists(atPath: root.appendingPathComponent("active.json").path) else { return } - for directory in attemptDirectories(at: root) { + for directory in (try? attemptDirectories(at: root)) ?? [] { // Failure only affects disk usage. Preflight measures the actual // remaining free capacity after these attempts, not estimated savings. try? FileManager.default.removeItem(at: directory) } } - private static func attemptDirectories(at root: URL) -> [URL] { - guard let entries = try? FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return [] } - return entries.filter { entry in - guard UUID(uuidString: entry.lastPathComponent) != nil, - let attributes = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return false } - return attributes.isDirectory == true && attributes.isSymbolicLink != true + private static func attemptDirectories(at root: URL) throws -> [URL] { + let entries = try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + return try entries.filter { entry in + guard UUID(uuidString: entry.lastPathComponent) != nil else { return false } + let attributes = try entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isSymbolicLink != true else { + throw SQLite.Failure.unsupported("A migration snapshot is a symbolic link; refusing to follow it") + } + return attributes.isDirectory == true } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 23729b1aa92..b5551dae598 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -2574,6 +2574,9 @@ public class PlatformWalletManager: ObservableObject { ) } + // Snapshot cleanup must succeed before deleting keys or live rows. + try persistenceHandler.deleteCompletedMigrationSnapshots() + let identityIds = try persistenceHandler.identityIdsForWallet(walletId: walletId) // Wipe Keychain BEFORE the SwiftData identity deletion runs. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 304c197df47..366f5814a08 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -6261,7 +6261,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Wipe a wallet's SwiftData footprint. + /// Discard completed legacy-migration snapshots for this store before an + /// explicit wallet deletion, even when there are no live wallet rows left. + /// Full snapshots can contain multiple wallets; their removal leaves all + /// current live rows intact. Pending recovery and cleanup errors are fatal. + /// Call before removing associated keys so failures remain retryable. + public func deleteCompletedMigrationSnapshots() throws { + try onQueue { try deleteCompletedMigrationSnapshotsOnQueue() } + } + + private func deleteCompletedMigrationSnapshotsOnQueue() throws { + let urls = Set(modelContainer.configurations + .filter { !$0.isStoredInMemoryOnly }.map(\.url)) + for url in urls.sorted(by: { $0.path < $1.path }) { + try DashLegacySchemaBridge.deleteCompletedSnapshots(at: url) + } + } + + /// Wipe a wallet's SwiftData footprint, including completed store snapshots. public func deleteWalletData(walletId: Data) throws { SDKLogger.event( "persistence_wallet_delete_started", @@ -6270,6 +6287,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) try onQueue { do { + // Run before the first saved deletion, including retries where + // the wallet row is already absent. Stay on the handler queue. + try deleteCompletedMigrationSnapshotsOnQueue() let walletDescriptor = FetchDescriptor( predicate: walletRecordPredicate(walletId: walletId) ) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index ad192b61ff6..e0c4e28ff6a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -342,6 +342,109 @@ final class DashLegacySchemaMigrationTests: XCTestCase { } } + func testWalletDeletionRemovesMigrationSnapshotsWithoutReopeningCachedContainer() throws { + try withStore { url in + let survivorId = Data(repeating: 0x63, count: 32) + // Seed a second wallet into the actual historical fixture before + // migrating. Both wallet rows must be present in the retained copy. + try autoreleasepool { + let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) + var columns: [String] = [] + try connection.query("PRAGMA table_info(ZPERSISTENTWALLET)") { + columns.append(String(cString: sqlite3_column_text($0, 1))) + } + let names = columns.map { "\"\($0)\"" }.joined(separator: ",") + let values = columns.map { column in + switch column { + case "Z_PK": return "Z_PK + 1" + case "ZWALLETID": return "X'" + survivorId.map { String(format: "%02x", $0) }.joined() + "'" + case "ZNAME": return "'surviving wallet'" + default: return "\"\(column)\"" + } + }.joined(separator: ",") + try connection.execute("INSERT INTO ZPERSISTENTWALLET (\(names)) SELECT \(values) FROM ZPERSISTENTWALLET LIMIT 1") + try connection.execute("UPDATE Z_PRIMARYKEY SET Z_MAX=(SELECT MAX(Z_PK) FROM ZPERSISTENTWALLET) WHERE Z_NAME='PersistentWallet'") + } + let container = try DashModelContainer.create(url: url) + let snapshot = try XCTUnwrap(operationDirectories(url).first).appendingPathComponent("original.store") + var originalWalletCount: Int64 = 0 + try DashLegacyStoreSQLite.Connection(snapshot, writable: false).query("SELECT COUNT(*) FROM ZPERSISTENTWALLET") { + originalWalletCount = sqlite3_column_int64($0, 0) + } + XCTAssertEqual(originalWalletCount, 2) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.map(\.walletId), [survivorId]) + XCTAssertEqual(rows.first?.name, "surviving wallet") + } + } + + func testDeleteAllClearsSnapshotsForEmptyInactiveStoreWithoutTouchingAnotherStore() throws { + try withStore { activeURL in + try withStore { inactiveURL in + let active = try DashModelContainer.create(url: activeURL) + let inactive = try DashModelContainer.create(url: inactiveURL) + let activeSnapshots = try operationDirectories(activeURL) + XCTAssertEqual(activeSnapshots.count, 1) + // Reproduce an earlier deletion path that removed rows while + // leaving a snapshot, without reopening the cached container. + let context = ModelContext(inactive) + for wallet in try context.fetch(FetchDescriptor()) { context.delete(wallet) } + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: inactive, network: .testnet) + XCTAssertTrue(handler.restorableWalletIds().isEmpty) + try handler.deleteCompletedMigrationSnapshots() + XCTAssertTrue(try operationDirectories(inactiveURL).isEmpty) + XCTAssertEqual(try operationDirectories(activeURL), activeSnapshots) + XCTAssertEqual(try ModelContext(active).fetchCount(FetchDescriptor()), 1) + XCTAssertEqual(try ModelContext(inactive).fetchCount(FetchDescriptor()), 0) + } + } + } + + func testSnapshotDeletionFailurePreservesLiveWalletAndCanBeRetried() throws { + try withStore { url in + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let snapshots = try operationDirectories(url) + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: root.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) } + XCTAssertThrowsError(try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32))) + XCTAssertEqual(try operationDirectories(url), snapshots) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 1) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: root.path) + try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32)) + XCTAssertTrue(try operationDirectories(url).isEmpty) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 0) + } + } + + func testSnapshotDeletionRefusesPendingRecoveryBeforeDeletingLiveRows() throws { + try withStore { url in + XCTAssertThrowsError(try open(url, hooks: .init(visit: { phase, _ in + if phase == .afterCommit { throw Injected.stop } + }))) + let root = DashLegacySchemaBridge.backupDirectory(for: url) + let marker = root.appendingPathComponent("active.json") + let evidence = try Data(contentsOf: marker) + let snapshots = try operationDirectories(url) + // Bypass factory recovery only to exercise the deletion boundary + // when a cached container and a pending recovery marker coexist. + let schema = DashModelContainer.schema + let container = try ModelContainer(for: schema, migrationPlan: DashMigrationPlan.self, + configurations: [ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)]) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertThrowsError(try handler.deleteWalletData(walletId: Data(repeating: 0x61, count: 32))) + XCTAssertThrowsError(try handler.deleteCompletedMigrationSnapshots()) + XCTAssertEqual(try Data(contentsOf: marker), evidence) + XCTAssertEqual(try operationDirectories(url), snapshots) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor()), 1) + } + } + func testCommittedWALDataIsIncludedAndConcurrentWriterIsLockedOutAtPromotion() throws { try withStore { url in let connection = try DashLegacyStoreSQLite.Connection(url, writable: true) From 1645f9cd6f0bee6341faf9e4a972ed2a49e77823 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 21 Sep 2026 09:04:22 +0200 Subject: [PATCH 18/18] fix(ci): support spaced paths on the Swift SDK runner --- .github/workflows/swift-sdk-build.yml | 33 ++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/.github/workflows/swift-sdk-build.yml b/.github/workflows/swift-sdk-build.yml index 2502526fa37..6a5c952fb9c 100644 --- a/.github/workflows/swift-sdk-build.yml +++ b/.github/workflows/swift-sdk-build.yml @@ -20,6 +20,10 @@ jobs: || github.event.pull_request.head.repo.owner.login == 'thepastaclaw' runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 90 + defaults: + run: + # The runner's work/temp directory can be on a volume with spaces. + shell: bash --noprofile --norc -e -o pipefail "{0}" steps: - name: Checkout repository @@ -36,7 +40,28 @@ jobs: # Rust + Cargo cache to speed up FFI build - name: Set up Rust toolchain (stable) - uses: dtolnay/rust-toolchain@stable + # Composite actions choose their own shell and do not inherit the + # quoted script path above. Keep setup here for space-safe execution. + env: + RUSTUP_PERMIT_COPY_RENAME: "1" + run: | + export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" + export PATH="$CARGO_HOME/bin:$PATH" + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused \ + --location --silent --show-error --fail https://sh.rustup.rs \ + | sh -s -- --default-toolchain none --no-modify-path -y + fi + rustup toolchain install stable --profile minimal --no-self-update \ + --target aarch64-apple-ios --target aarch64-apple-ios-sim + { + echo "CARGO_HOME=$CARGO_HOME" + echo "RUSTUP_TOOLCHAIN=stable" + echo "CARGO_INCREMENTAL=${CARGO_INCREMENTAL-0}" + echo "CARGO_TERM_COLOR=${CARGO_TERM_COLOR-always}" + } >> "$GITHUB_ENV" + echo "$CARGO_HOME/bin" >> "$GITHUB_PATH" + rustc +stable --version --verbose - name: Restore cargo registry cache uses: actions/cache/restore@v5 @@ -48,10 +73,6 @@ jobs: restore-keys: | cargo-registry- - - name: Add iOS Rust targets - run: | - rustup target add aarch64-apple-ios aarch64-apple-ios-sim - - name: Restore cached Protobuf (protoc) id: cache-protoc uses: actions/cache@v5 @@ -88,7 +109,7 @@ jobs: - name: Verify protoc and export env run: | set -euxo pipefail - echo "$HOME/.local/protoc-32.0/bin" >> $GITHUB_PATH + echo "$HOME/.local/protoc-32.0/bin" >> "$GITHUB_PATH" echo "PROTOC=$HOME/.local/protoc-32.0/bin/protoc" >> "$GITHUB_ENV" "$HOME/.local/protoc-32.0/bin/protoc" --version