diff --git a/.editorconfig b/.editorconfig index 4238a16b9d5..2e9dc07ba04 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,9 @@ 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 + [*.{md,markdown}] trim_trailing_whitespace = false 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 diff --git a/.github/workflows/swift-sdk-freeze-release.yml b/.github/workflows/swift-sdk-freeze-release.yml new file mode 100644 index 00000000000..00dec5251bb --- /dev/null +++ b/.github/workflows/swift-sdk-freeze-release.yml @@ -0,0 +1,62 @@ +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 + # Include immutable swift-schema-source/* tags and their history. + 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 -I packages/swift-sdk/scripts/freeze_appstore_release.py "${args[@]}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 86d9675f743..ef36f1b9c79 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/** @@ -495,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/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 new file mode 100644 index 00000000000..2c4e67b1aff --- /dev/null +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -0,0 +1,268 @@ +# 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 V2–V5 schemas have been collapsed +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. 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 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 +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:)` 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 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 +`original.store` backup through that launch. A later successful ordinary open +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. +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 +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. 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 +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 +[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. + +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 +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. + +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 +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, 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 +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 + +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 +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 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. +- 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. + +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 +``` + +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/DashLegacySchemaBridge.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift new file mode 100644 index 00000000000..7fd33cb0e58 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacySchemaBridge.swift @@ -0,0 +1,416 @@ +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 } + var availableCapacity: (URL) throws -> Int64 = DashLegacySchemaBridge.availableCapacity(at:) + } + 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 + let destinationData: SQLite.StoreEvidence? + } + + 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() } + 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. + 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() + reclaimAfterSuccessfulOpen(at: url, root: root) + return container + } + } + 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 + // 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"] + 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) + // 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 { + 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) + 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: 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] { + 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() + // 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 + } + + static func backupDirectory(for url: URL) -> URL { + 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 { + 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 { + 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 + } + + /// 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 let directories = try? attemptDirectories(at: root), !directories.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 (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) 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 + } + } + + 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 [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 { + try SQLite.integrityCheck(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 { + // 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 opener is using 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..4e5e1fe6784 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashLegacyStoreSQLite.swift @@ -0,0 +1,315 @@ +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 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." + } + } + } + + 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 failure = sqliteFailure(handle: result, operation: "opening a database", status: status) + sqlite3_close(result) + throw failure + } + 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 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. + 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?() + 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 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 + // 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") + } + 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 { + 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] + } + + 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. + 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 53d9399b1a2..24555d75bc6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -1,28 +1,11 @@ import Foundation +import Dispatch 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,81 +53,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] - } - - /// The exact model set registered as schema V4: the same entities as - /// V3, with the wallet transaction models V4 widened. Frozen as its own - /// whole graph rather than as a row for the three models that changed: - /// `PersistentTxo`, `PersistentPendingInput` and `PersistentWallet` all - /// carry relationships, and a frozen model naming a relationship target - /// that its own schema does not declare binds that bare name to the live - /// type (the earlier partial rows get away with it because the models - /// they freeze are relationship-isolated). - fileprivate static var v4ModelTypes: [any PersistentModel.Type] { - [ - DashSchemaV4.PersistentIdentity.self, - DashSchemaV4.PersistentDPNSName.self, - DashSchemaV4.PersistentDashpayProfile.self, - DashSchemaV4.PersistentDashpayContactProfile.self, - DashSchemaV4.PersistentDashpayContactRequest.self, - DashSchemaV4.PersistentDashpayPayment.self, - DashSchemaV4.PersistentDashpayIgnoredSender.self, - DashSchemaV4.PersistentDocument.self, - DashSchemaV4.PersistentDataContract.self, - DashSchemaV4.PersistentPublicKey.self, - DashSchemaV4.PersistentTokenBalance.self, - DashSchemaV4.PersistentKeyword.self, - DashSchemaV4.PersistentToken.self, - DashSchemaV4.PersistentDocumentType.self, - DashSchemaV4.PersistentIndex.self, - DashSchemaV4.PersistentProperty.self, - DashSchemaV4.PersistentTokenHistoryEvent.self, - DashSchemaV4.PersistentPlatformAddress.self, - DashSchemaV4.PersistentPlatformAddressesSyncState.self, - DashSchemaV4.PersistentWallet.self, - DashSchemaV4.PersistentAccount.self, - DashSchemaV4.PersistentCoreAddress.self, - DashSchemaV4.PersistentTransaction.self, - DashSchemaV4.PersistentTxo.self, - DashSchemaV4.PersistentPendingInput.self, - DashSchemaV4.PersistentWalletManagerMetadata.self, - DashSchemaV4.PersistentShieldedNote.self, - DashSchemaV4.PersistentShieldedOutgoingNote.self, - DashSchemaV4.PersistentShieldedSyncState.self, - DashSchemaV4.PersistentShieldedActivity.self, - DashSchemaV4.PersistentShieldedViewingKey.self, - DashSchemaV4.PersistentAssetLock.self, - DashSchemaV4.PersistentInvitation.self, - DashSchemaV4.PersistentMasternode.self, - DashSchemaV4.PersistentTrackedMasternode.self - ] - } - - /// All persistent model types in the current Dash SDK schema (V5). - /// 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, @@ -187,10 +98,15 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV5.self) + 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 @@ -206,14 +122,15 @@ 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 { + /// 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, url: url, @@ -223,17 +140,40 @@ 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 /// 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] @@ -254,144 +194,26 @@ 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, - DashSchemaV5.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: DashSchemaV4.self, toVersion: DashSchemaV5.self) + .lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV2.self) ] } } -/// 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 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) @@ -402,116 +224,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, sweep, +/// public-key usage limits, and contract-bound variants. 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.v4ModelTypes - } -} - -/// Version 5 adds three optional columns to `PersistentPublicKey`, on the -/// same entity set as V4: -/// - `totalBudget`: the credits an authentication key may take from its -/// identity over its whole lifetime, `nil` for a key registered without -/// a budget. Signed carrier for the protocol's unsigned `Credits`, read -/// through `totalBudgetCredits`. -/// - `expiresAt`: the block time in milliseconds from which the key can -/// no longer sign, `nil` for a key registered without an expiry. Read -/// through `expiresAtMillis`. -/// - `contractBoundsKind`: the FFI `contract_bounds_kind` discriminant -/// (0 none, 1 SingleContract, 2 SingleContractDocumentType, -/// 3 ContractGroup) the key row was written with. The two columns V4 -/// had (`contractBoundsData`, `contractBoundsDocumentTypeName`) cannot -/// tell a contract-group bound apart from a whole-contract one, so -/// restoring an identity that held a group-bound AUTHENTICATION key -/// brought the key back unbounded and changed its authorization -/// metadata. `NULL` reads as "legacy row, infer the variant the way V4 -/// did" (`PersistentPublicKey.effectiveContractBoundsKind`). -/// The two limits are what make a key an `IdentityPublicKey::V1` (protocol -/// version 14); without the columns a limited key would come back unlimited -/// on cold restart and the wallet would offer it for signing work consensus -/// refuses. Existing rows migrate with all three `NULL`, which is exactly "a -/// version 0 key, no limits, bounds as V4 stored them". -/// -/// All three columns are additive and optional, so a lightweight migration -/// preserves every existing row. The bounds kind joined V5 before the -/// version shipped, in the change that rewrote the `dash-v5` fixture store -/// on purpose (see the `DashModelContainer.modelTypes` doc); a store written -/// by a V5 build from before that change matches no registered version. -/// -/// Registering V5 required freezing every model V4 registers: the generated -/// copies under `FrozenSchemas/`, see `scripts/freeze_schema_models.py`. -/// V4 needed the whole graph rather than a row for `PersistentPublicKey` -/// alone: see `DashModelContainer.v4ModelTypes`. -public enum DashSchemaV5: VersionedSchema { - public static var versionIdentifier: Schema.Version { - Schema.Version(5, 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/FrozenSchemas/DashSchemaV4+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift deleted file mode 100644 index e5c5757dfca..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAccount.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentAccount` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentAccount { - #Unique([ - \.wallet, - \.accountType, - \.accountIndex, - \.standardTag, - \.registrationIndex, - \.keyClass, - \.userIdentityId, - \.friendIdentityId, - ]) - - var accountType: UInt32 - var accountIndex: UInt32 - var accountTypeName: String - var balanceConfirmed: UInt64 - var balanceUnconfirmed: UInt64 - var externalHighestUsed: Int32 - var internalHighestUsed: Int32 - var standardTag: UInt8 - var registrationIndex: UInt32 - var keyClass: UInt32 - var userIdentityId: Data - var friendIdentityId: Data - @Attribute(.unique) var accountExtendedPubKeyBytes: Data? - var createdAt: Date - var lastUpdated: Date - - var wallet: PersistentWallet - - @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) - var coreAddresses: [PersistentCoreAddress] - - @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) - var platformAddresses: [PersistentPlatformAddress] - - var involvedTransactions: [PersistentTransaction] = [] - - init( - wallet: PersistentWallet, - accountType: UInt32, - accountIndex: UInt32, - accountTypeName: String - ) { - self.wallet = wallet - self.accountType = accountType - self.accountIndex = accountIndex - self.accountTypeName = accountTypeName - self.balanceConfirmed = 0 - self.balanceUnconfirmed = 0 - self.externalHighestUsed = -1 - self.internalHighestUsed = -1 - self.standardTag = 0 - self.registrationIndex = 0 - self.keyClass = 0 - self.userIdentityId = Data() - self.friendIdentityId = Data() - self.accountExtendedPubKeyBytes = nil - self.createdAt = Date() - self.lastUpdated = Date() - self.coreAddresses = [] - self.platformAddresses = [] - self.involvedTransactions = [] - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift deleted file mode 100644 index f693fa47678..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentAssetLock.swift +++ /dev/null @@ -1,102 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentAssetLock` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @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 DashSchemaV4.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 DashSchemaV4.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/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift deleted file mode 100644 index f2949819ba4..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentCoreAddress.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentCoreAddress` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentCoreAddress { - @Attribute(.unique) var address: String - var publicKey: Data - var keyType: UInt8 = 0 - var poolTypeTag: UInt8 - var addressIndex: UInt32 - var derivationPath: String - var isUsed: Bool - var firstSeenHeight: UInt32 - var lastSeenHeight: UInt32 - var balance: UInt64 - var createdAt: Date - var lastUpdated: Date - - var account: PersistentAccount? - - @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) - var txos: [PersistentTxo] = [] - - init( - address: String, - publicKey: Data = Data(), - keyType: UInt8 = 0, - poolTypeTag: UInt8, - addressIndex: UInt32, - derivationPath: String, - isUsed: Bool = false, - balance: UInt64 = 0 - ) { - self.address = address - self.publicKey = publicKey - self.keyType = keyType - self.poolTypeTag = poolTypeTag - self.addressIndex = addressIndex - self.derivationPath = derivationPath - self.isUsed = isUsed - self.firstSeenHeight = 0 - self.lastSeenHeight = 0 - self.balance = balance - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentCoreAddress { - var poolTypeName: String { - switch poolTypeTag { - case 0: return "External" - case 1: return "Internal" - case 2: return "Additional" - case 3: return "Additional (Hardened)" - default: return "Unknown(\(poolTypeTag))" - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift deleted file mode 100644 index 893dc47285e..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDPNSName.swift +++ /dev/null @@ -1,132 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDPNSName` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDPNSName { - #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var label: String - - var normalizedLabel: String - - var parentDomainName: String - - var normalizedParentDomainName: String - - var acquiredAt: UInt64 - - var isOwned: Bool = true - - var documentIdBase58: String? - - var priceCredits: Int64? - - var saleStatusRaw: Int16 = 0 - - var counterpartyIdBase58: String? - - var documentCreatedAtMs: UInt64? - - var documentUpdatedAtMs: UInt64? - - var documentTransferredAtMs: UInt64? - - var marketplaceUpdatedAt: UInt64 = 0 - - var identity: PersistentIdentity - - var createdAt: Date - var lastUpdated: Date - - init( - identity: PersistentIdentity, - label: String, - parentDomainName: String = "dash", - acquiredAt: UInt64 = 0, - isOwned: Bool = true - ) { - self.identity = identity - self.networkRaw = identity.networkRaw - self.label = label - self.normalizedLabel = Self.normalize(label) - self.parentDomainName = parentDomainName - self.normalizedParentDomainName = Self.normalize(parentDomainName) - self.acquiredAt = acquiredAt - self.isOwned = isOwned - self.documentIdBase58 = nil - self.priceCredits = nil - self.saleStatusRaw = 0 - self.counterpartyIdBase58 = nil - self.documentCreatedAtMs = nil - self.documentUpdatedAtMs = nil - self.documentTransferredAtMs = nil - self.marketplaceUpdatedAt = 0 - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDPNSName { - var saleStatus: DpnsNameSaleStatus? { - guard documentIdBase58 != nil else { return nil } - switch saleStatusRaw { - case 0: - return .owned - case 1: - guard let to = counterpartyId else { return nil } - return .sold(to: to) - case 2: - guard let to = counterpartyId else { return nil } - return .transferred(to: to) - default: - return nil - } - } - - var counterpartyId: Data? { - counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } - } - - var listedPriceCredits: UInt64? { - guard documentIdBase58 != nil, let priceCredits else { return nil } - return UInt64(bitPattern: priceCredits) - } -} - -extension DashSchemaV4.PersistentDPNSName { - static func normalize(_ input: String) -> String { - String(input.map { c -> Character in - switch c { - case "o", "O": return "0" - case "i", "I": return "1" - case "l", "L": return "1" - default: return Character(c.lowercased()) - } - }) - } -} - -extension DashSchemaV4.PersistentDPNSName { - static func predicate(identityId: Data) -> Predicate { - let target = identityId - return #Predicate { name in - name.identity.identityId == target && name.isOwned == true - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift deleted file mode 100644 index 5053e0e2a28..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactProfile.swift +++ /dev/null @@ -1,97 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDashpayContactProfile` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDashpayContactProfile { - #Unique([ - \.networkRaw, \.ownerIdentityId, \.contactIdentityId - ]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var ownerIdentityId: Data - - var contactIdentityId: Data - - var displayName: String? - - var publicMessage: String? - - var bio: String? - - var avatarUrl: String? - - var avatarHash: Data? - - var avatarFingerprint: Data? - - var checkedAtMs: UInt64 - - var owner: PersistentIdentity - - var createdAt: Date - var lastUpdated: Date - - init( - owner: PersistentIdentity, - contactIdentityId: Data, - checkedAtMs: UInt64, - displayName: String? = nil, - publicMessage: String? = nil, - bio: String? = nil, - avatarUrl: String? = nil, - avatarHash: Data? = nil, - avatarFingerprint: Data? = nil - ) { - self.owner = owner - self.networkRaw = owner.networkRaw - self.ownerIdentityId = owner.identityId - self.contactIdentityId = contactIdentityId - self.checkedAtMs = checkedAtMs - self.displayName = displayName - self.publicMessage = publicMessage - self.bio = bio - self.avatarUrl = avatarUrl - self.avatarHash = avatarHash - self.avatarFingerprint = avatarFingerprint - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDashpayContactProfile { - static func predicate( - ownerIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - return #Predicate { row in - row.ownerIdentityId == target - } - } - - static func predicate( - ownerIdentityId: Data, - contactIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - let contact = contactIdentityId - return #Predicate { row in - row.ownerIdentityId == target - && row.contactIdentityId == contact - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift deleted file mode 100644 index 744c5572ddf..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayContactRequest.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDashpayContactRequest` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDashpayContactRequest { - #Unique([ - \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing - ]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var ownerIdentityId: Data - - var contactIdentityId: Data - - var isOutgoing: Bool - - var senderKeyIndex: UInt32 - - var recipientKeyIndex: UInt32 - - var accountReference: UInt32 - - var encryptedPublicKey: Data - - var encryptedAccountLabel: Data? - - var autoAcceptProof: Data? - - var coreHeightCreatedAt: UInt32 - - var createdAtMillis: UInt64 - - var paymentChannelBroken: Bool = false - - var contactAlias: String? - - var contactNote: String? - - var contactHidden: Bool = false - - var contactAccountLabel: String? - - var contactAcceptedAccounts: [UInt32] = [] - - var owner: PersistentIdentity - - var createdAt: Date - var lastUpdated: Date - - init( - owner: PersistentIdentity, - contactIdentityId: Data, - isOutgoing: Bool, - senderKeyIndex: UInt32, - recipientKeyIndex: UInt32, - accountReference: UInt32, - encryptedPublicKey: Data, - encryptedAccountLabel: Data? = nil, - autoAcceptProof: Data? = nil, - coreHeightCreatedAt: UInt32, - createdAtMillis: UInt64, - paymentChannelBroken: Bool = false - ) { - self.owner = owner - self.networkRaw = owner.networkRaw - self.ownerIdentityId = owner.identityId - self.contactIdentityId = contactIdentityId - self.isOutgoing = isOutgoing - self.senderKeyIndex = senderKeyIndex - self.recipientKeyIndex = recipientKeyIndex - self.accountReference = accountReference - self.encryptedPublicKey = encryptedPublicKey - self.encryptedAccountLabel = encryptedAccountLabel - self.autoAcceptProof = autoAcceptProof - self.coreHeightCreatedAt = coreHeightCreatedAt - self.createdAtMillis = createdAtMillis - self.paymentChannelBroken = paymentChannelBroken - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDashpayContactRequest { - static func predicate( - ownerIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - return #Predicate { row in - row.ownerIdentityId == target - } - } - - static func predicate( - ownerIdentityId: Data, - isOutgoing: Bool - ) -> Predicate { - let target = ownerIdentityId - let direction = isOutgoing - return #Predicate { row in - row.ownerIdentityId == target && row.isOutgoing == direction - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift deleted file mode 100644 index d332a6ffa47..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayIgnoredSender.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDashpayIgnoredSender { - #Unique([ - \.networkRaw, \.ownerIdentityId, \.ignoredSenderId - ]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var ownerIdentityId: Data - - var ignoredSenderId: Data - - var owner: PersistentIdentity - - var ignoredAt: Date - - init( - owner: PersistentIdentity, - ignoredSenderId: Data - ) { - self.owner = owner - self.networkRaw = owner.networkRaw - self.ownerIdentityId = owner.identityId - self.ignoredSenderId = ignoredSenderId - self.ignoredAt = Date() - } - } -} - -extension DashSchemaV4.PersistentDashpayIgnoredSender { - static func predicate( - ownerIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - return #Predicate { row in - row.ownerIdentityId == target - } - } - - static func predicate( - ownerIdentityId: Data, - ignoredSenderId: Data - ) -> Predicate { - let target = ownerIdentityId - let sender = ignoredSenderId - return #Predicate { row in - row.ownerIdentityId == target - && row.ignoredSenderId == sender - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift deleted file mode 100644 index 7dc4b69c6f1..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayPayment.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDashpayPayment` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDashpayPayment { - #Unique([ - \.networkRaw, \.ownerIdentityId, \.txid - ]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var ownerIdentityId: Data - - var counterpartyIdentityId: Data - - var amountDuffs: UInt64 - - var directionRaw: UInt8 - - var direction: DashPayPaymentDirection { - get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } - set { directionRaw = newValue.rawValue } - } - - var statusRaw: UInt8 - - var status: DashPayPaymentStatus { - get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } - set { statusRaw = newValue.rawValue } - } - - var txid: String - - var memo: String? - - var owner: PersistentIdentity - - var createdAt: Date - var lastUpdated: Date - - init( - owner: PersistentIdentity, - counterpartyIdentityId: Data, - amountDuffs: UInt64, - direction: DashPayPaymentDirection, - status: DashPayPaymentStatus, - txid: String, - memo: String? = nil - ) { - self.owner = owner - self.networkRaw = owner.networkRaw - self.ownerIdentityId = owner.identityId - self.counterpartyIdentityId = counterpartyIdentityId - self.amountDuffs = amountDuffs - self.directionRaw = direction.rawValue - self.statusRaw = status.rawValue - self.txid = txid - self.memo = memo - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDashpayPayment { - static func predicate( - ownerIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - return #Predicate { row in - row.ownerIdentityId == target - } - } - - static func predicate( - ownerIdentityId: Data, - counterpartyIdentityId: Data - ) -> Predicate { - let target = ownerIdentityId - let counterparty = counterpartyIdentityId - return #Predicate { row in - row.ownerIdentityId == target - && row.counterpartyIdentityId == counterparty - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift deleted file mode 100644 index 49ecf016725..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDashpayProfile.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDashpayProfile` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDashpayProfile { - #Unique([\.networkRaw, \.identity]) - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var displayName: String? - - var publicMessage: String? - - var bio: String? - - var avatarUrl: String? - - var avatarHash: Data? - - var avatarFingerprint: Data? - - var identity: PersistentIdentity - - var createdAt: Date - var lastUpdated: Date - - init( - identity: PersistentIdentity, - displayName: String? = nil, - publicMessage: String? = nil, - bio: String? = nil, - avatarUrl: String? = nil, - avatarHash: Data? = nil, - avatarFingerprint: Data? = nil - ) { - self.identity = identity - self.networkRaw = identity.networkRaw - self.displayName = displayName - self.publicMessage = publicMessage - self.bio = bio - self.avatarUrl = avatarUrl - self.avatarHash = avatarHash - self.avatarFingerprint = avatarFingerprint - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDashpayProfile { - static func predicate(identityId: Data) -> Predicate { - let target = identityId - return #Predicate { profile in - profile.identity.identityId == target - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift deleted file mode 100644 index 58068875996..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDataContract.swift +++ /dev/null @@ -1,286 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDataContract` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDataContract { - #Index([\.networkRaw]) - - @Attribute(.unique) var id: Data - var name: String - var serializedContract: Data - var createdAt: Date - var lastAccessedAt: Date - - var binarySerialization: Data? - - var version: Int? - var ownerId: Data? - - @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) - var keywordRelations: [PersistentKeyword] - var contractDescription: String? - - var schemaData: Data - var documentTypesData: Data - - var groupsData: Data? - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var lastUpdated: Date - var lastSyncedAt: Date? - - var canBeDeleted: Bool - var readonly: Bool - var keepsHistory: Bool - var schemaDefs: Int? - - var documentsKeepHistoryContractDefault: Bool - var documentsMutableContractDefault: Bool - var documentsCanBeDeletedContractDefault: Bool - - @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) - var tokens: [PersistentToken]? - - @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) - var documentTypes: [PersistentDocumentType]? - - @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) - var documents: [PersistentDocument] - - @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) - var ownerIdentity: PersistentIdentity? - - var hasTokens: Bool - var tokensData: Data? - - var idBase58: String { - id.toBase58String() - } - - var ownerIdBase58: String? { - ownerId?.toBase58String() - } - - var parsedContract: [String: Any]? { - try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] - } - - var binarySerializationHex: String? { - binarySerialization?.toHexString() - } - - var keywords: [String] { - keywordRelations.map { $0.keyword } - } - - var schema: [String: Any] { - get { - guard let json = try? JSONSerialization.jsonObject(with: schemaData), - let dict = json as? [String: Any] else { - return [:] - } - return dict - } - set { - schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() - lastUpdated = Date() - } - } - - var documentTypesList: [String] { - get { - guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), - let array = json as? [String] else { - return [] - } - return array - } - set { - documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() - lastUpdated = Date() - } - } - - var tokenConfigurations: [String: Any]? { - get { - guard let data = tokensData, - let json = try? JSONSerialization.jsonObject(with: data), - let dict = json as? [String: Any] else { - return nil - } - return dict - } - set { - if let newValue = newValue { - tokensData = try? JSONSerialization.data(withJSONObject: newValue) - hasTokens = true - } else { - tokensData = nil - hasTokens = false - } - lastUpdated = Date() - } - } - - var groups: [String: Any]? { - get { - guard let data = groupsData, - let json = try? JSONSerialization.jsonObject(with: data), - let dict = json as? [String: Any] else { - return nil - } - return dict - } - set { - if let newValue = newValue { - groupsData = try? JSONSerialization.data(withJSONObject: newValue) - } else { - groupsData = nil - } - lastUpdated = Date() - } - } - - init( - id: Data, - name: String, - serializedContract: Data, - version: Int? = 1, - ownerId: Data? = nil, - schema: [String: Any] = [:], - documentTypesList: [String] = [], - keywords: [String] = [], - description: String? = nil, - hasTokens: Bool = false, - network: Network - ) { - self.id = id - self.name = name - self.serializedContract = serializedContract - self.createdAt = Date() - self.lastAccessedAt = Date() - self.version = version - self.ownerId = ownerId - - self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() - self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() - - self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } - self.contractDescription = description - - self.hasTokens = hasTokens - self.tokensData = nil - - self.groupsData = nil - - self.documents = [] - - self.ownerIdentity = nil - - self.networkRaw = network.rawValue - self.lastUpdated = Date() - self.lastSyncedAt = nil - - self.canBeDeleted = false - self.readonly = false - self.keepsHistory = false - self.documentsKeepHistoryContractDefault = false - self.documentsMutableContractDefault = true - self.documentsCanBeDeletedContractDefault = true - } - - func updateLastAccessed() { - self.lastAccessedAt = Date() - } - - func updateVersion(_ newVersion: Int) { - self.version = newVersion - self.lastUpdated = Date() - } - - func markAsSynced() { - self.lastSyncedAt = Date() - } - - func addDocument(_ document: PersistentDocument) { - documents.append(document) - lastUpdated = Date() - } - - func removeDocument(withId documentId: String) { - if let docIdData = Data.identifier(fromBase58: documentId) { - documents.removeAll { $0.id == docIdData } - } - lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentDataContract { - static func predicate(contractId: String) -> Predicate { - guard let idData = Data.identifier(fromBase58: contractId) else { - return #Predicate { _ in false } - } - return #Predicate { contract in - contract.id == idData - } - } - - static func predicate(ownerId: Data) -> Predicate { - #Predicate { contract in - contract.ownerId == ownerId - } - } - - static func predicate(name: String) -> Predicate { - #Predicate { contract in - contract.name.localizedStandardContains(name) - } - } - - static var contractsWithTokensPredicate: Predicate { - #Predicate { contract in - contract.hasTokens == true - } - } - - static func predicate(keyword: String) -> Predicate { - #Predicate { contract in - contract.keywordRelations.contains { $0.keyword == keyword } - } - } - - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { contract in - contract.lastSyncedAt == nil || contract.lastSyncedAt! < date - } - } - - static func predicate(network: Network) -> Predicate { - let target = network.rawValue - return #Predicate { contract in - contract.networkRaw == target - } - } - - static func contractsWithTokensPredicate(network: Network) -> Predicate { - let target = network.rawValue - return #Predicate { contract in - contract.hasTokens == true && contract.networkRaw == target - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift deleted file mode 100644 index 896b6010580..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocument.swift +++ /dev/null @@ -1,181 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDocument` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDocument { - #Index([\.networkRaw]) - - @Attribute(.unique) var documentId: String - - var documentType: String - var revision: Int32 - var data: Data - - var contractId: String - var ownerId: String - - var contractIdData: Data - var ownerIdData: Data - - var createdAt: Date - var updatedAt: Date - var transferredAt: Date? - - var createdAtBlockHeight: Int64? - var updatedAtBlockHeight: Int64? - var transferredAtBlockHeight: Int64? - - var createdAtCoreBlockHeight: Int64? - var updatedAtCoreBlockHeight: Int64? - var transferredAtCoreBlockHeight: Int64? - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var isDeleted: Bool = false - - var localCreatedAt: Date - var localUpdatedAt: Date - - var documentType_relation: PersistentDocumentType? - var dataContract: PersistentDataContract? - - var ownerIdentity: PersistentIdentity? - - var id: Data { - Data.identifier(fromBase58: documentId) ?? Data() - } - - var idBase58: String { - documentId - } - - var ownerIdBase58: String { - ownerId - } - - var contractIdBase58: String { - contractId - } - - var properties: [String: Any]? { - try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } - - var displayTitle: String { - guard let props = properties else { return "Document" } - - if let title = props["title"] as? String { return title } - if let name = props["name"] as? String { return name } - if let label = props["label"] as? String { return label } - if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } - - return documentType - } - - var summary: String { - var parts: [String] = [] - - parts.append("Type: \(documentType)") - parts.append("Rev: \(revision)") - - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .gregorian) - formatter.dateStyle = .short - parts.append("Created: \(formatter.string(from: createdAt))") - - return parts.joined(separator: " • ") - } - - init( - documentId: String, - documentType: String, - revision: Int32, - data: Data, - contractId: String, - ownerId: String, - network: Network - ) { - self.documentId = documentId - self.documentType = documentType - self.revision = revision - self.data = data - self.contractId = contractId - self.ownerId = ownerId - self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() - self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() - self.networkRaw = network.rawValue - self.createdAt = Date() - self.updatedAt = Date() - self.localCreatedAt = Date() - self.localUpdatedAt = Date() - } - - func updateProperties(_ newData: Data) { - self.data = newData - self.updatedAt = Date() - } - - func updateRevision(_ newRevision: Int64) { - self.revision = Int32(newRevision) - self.updatedAt = Date() - } - - func markAsDeleted() { - self.isDeleted = true - self.updatedAt = Date() - } - - static func predicate(documentId: String) -> Predicate { - #Predicate { doc in - doc.documentId == documentId && doc.isDeleted == false - } - } - - static func predicate(contractId: String, network: Network) -> Predicate { - let target = network.rawValue - return #Predicate { doc in - doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false - } - } - - static func predicate(ownerId: Data) -> Predicate { - let ownerIdString = ownerId.toBase58String() - return #Predicate { doc in - doc.ownerId == ownerIdString && doc.isDeleted == false - } - } - - func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { - guard ownerIdentity == nil else { return } - - let ownerIdToMatch = self.ownerIdData - let identityPredicate = #Predicate { identity in - identity.identityId == ownerIdToMatch && identity.isLocal == true - } - - let descriptor = FetchDescriptor(predicate: identityPredicate) - - do { - if let localIdentity = try modelContext.fetch(descriptor).first { - self.ownerIdentity = localIdentity - self.localUpdatedAt = Date() - } - } catch { - print("Failed to link document to local identity: \(error)") - } - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift deleted file mode 100644 index d6cbbf62bfd..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentDocumentType.swift +++ /dev/null @@ -1,101 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentDocumentType` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentDocumentType { - @Attribute(.unique) var id: Data - var contractId: Data - var name: String - - var schemaJSON: Data - var propertiesJSON: Data - - var documentsKeepHistory: Bool - var documentsMutable: Bool - var documentsCanBeDeleted: Bool - var documentsTransferable: Bool - - var indexOnly: Bool = false - - var requiredFieldsJSON: Data? - - var securityLevel: Int - - var tradeMode: Int - var creationRestrictionMode: Int - - var requiresIdentityEncryptionBoundedKey: Bool - var requiresIdentityDecryptionBoundedKey: Bool - - var createdAt: Date - var lastAccessedAt: Date - - var dataContract: PersistentDataContract? - - @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) - var documents: [PersistentDocument]? - - @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) - var indices: [PersistentIndex]? - - @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) - var propertiesList: [PersistentProperty]? - - init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { - var idData = contractId - idData.append(name.data(using: .utf8) ?? Data()) - self.id = idData - - self.contractId = contractId - self.name = name - self.schemaJSON = schemaJSON - self.propertiesJSON = propertiesJSON - self.documentsKeepHistory = false - self.documentsMutable = true - self.documentsCanBeDeleted = true - self.documentsTransferable = false - self.securityLevel = 0 - self.tradeMode = 0 - self.creationRestrictionMode = 0 - self.requiresIdentityEncryptionBoundedKey = false - self.requiresIdentityDecryptionBoundedKey = false - self.createdAt = Date() - self.lastAccessedAt = Date() - } - } -} - -extension DashSchemaV4.PersistentDocumentType { - var contractIdBase58: String { - contractId.toBase58String() - } - - var schema: [String: Any]? { - try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] - } - - var properties: [String: Any]? { - try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] - } - - var persistentProperties: [DashSchemaV4.PersistentProperty]? { - return propertiesList - } - - var requiredFields: [String]? { - guard let data = requiredFieldsJSON else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] - } - - var documentCount: Int { - documents?.count ?? 0 - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift deleted file mode 100644 index 9cf09acdedb..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIdentity.swift +++ /dev/null @@ -1,274 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentIdentity` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentIdentity { - #Index([\.networkRaw]) - - @Attribute(.unique) var identityId: Data - var balance: Int64 - var revision: Int64 - var isLocal: Bool - var alias: String? - var dpnsName: String? - var mainDpnsName: String? - var identityType: String - - var votingPrivateKeyIdentifier: String? - var ownerPrivateKeyIdentifier: String? - var payoutPrivateKeyIdentifier: String? - - @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] - - var createdAt: Date - var lastUpdated: Date - var lastSyncedAt: Date? - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - var wallet: PersistentWallet? - var identityIndex: UInt32 = 0 - - @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] - @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] - - @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) - var dpnsNames: [PersistentDPNSName] = [] - - @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) - var dashpayProfile: PersistentDashpayProfile? - - @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) - var contactRequests: [PersistentDashpayContactRequest] = [] - - @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) - var dashpayPayments: [PersistentDashpayPayment] = [] - - @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) - var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] - - @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) - var contactProfiles: [PersistentDashpayContactProfile] = [] - - var ownedDataContracts: [PersistentDataContract] - - init( - identityId: Data, - balance: Int64 = 0, - revision: Int64 = 0, - isLocal: Bool = true, - alias: String? = nil, - dpnsName: String? = nil, - mainDpnsName: String? = nil, - identityType: IdentityType = .user, - votingPrivateKeyIdentifier: String? = nil, - ownerPrivateKeyIdentifier: String? = nil, - payoutPrivateKeyIdentifier: String? = nil, - network: Network, - identityIndex: UInt32 = 0 - ) { - self.identityId = identityId - self.balance = balance - self.revision = revision - self.isLocal = isLocal - self.alias = alias - self.dpnsName = dpnsName - self.mainDpnsName = mainDpnsName - self.identityType = identityType.rawValue - self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier - self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier - self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier - self.networkRaw = network.rawValue - self.identityIndex = identityIndex - self.publicKeys = [] - self.documents = [] - self.tokenBalances = [] - self.dpnsNames = [] - self.dashpayProfile = nil - self.contactRequests = [] - self.dashpayPayments = [] - self.dashpayIgnoredSenders = [] - self.contactProfiles = [] - self.ownedDataContracts = [] - self.createdAt = Date() - self.lastUpdated = Date() - self.lastSyncedAt = nil - } - - var identityIdString: String { - identityId.toHexString() - } - - var identityIdBase58: String { - identityId.toBase58String() - } - - var formattedBalance: String { - let dashAmount = Double(balance) / 100_000_000_000 - return String(format: "%.8f DASH", dashAmount) - } - - var identityPublicKeys: [IdentityPublicKey] { - publicKeys.compactMap { $0.toIdentityPublicKey() } - } - - var displayName: String { - if let alias = alias, !alias.isEmpty { - return alias - } - if let mainDpnsName = mainDpnsName, !mainDpnsName.isEmpty { - return mainDpnsName - } - if let dpnsName = dpnsName, !dpnsName.isEmpty { - return dpnsName - } - return String(identityIdString.prefix(12)) + "..." - } - - var identityTypeEnum: IdentityType { - IdentityType(rawValue: identityType) ?? .user - } - - func updateBalance(_ newBalance: Int64) { - self.balance = newBalance - self.lastUpdated = Date() - } - - func updateRevision(_ newRevision: Int64) { - self.revision = newRevision - self.lastUpdated = Date() - } - - func markAsSynced() { - self.lastSyncedAt = Date() - } - - func updateDPNSName(_ name: String?) { - self.dpnsName = name - self.lastUpdated = Date() - } - - func addPublicKey(_ key: PersistentPublicKey) { - publicKeys.append(key) - lastUpdated = Date() - } - - func removePublicKey(withId keyId: Int32) { - publicKeys.removeAll { $0.keyId == keyId } - lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentIdentity { - static func predicate(identityId: Data) -> Predicate { - #Predicate { identity in - identity.identityId == identityId - } - } - - static var walletOwnedIdentitiesPredicate: Predicate { - #Predicate { identity in - identity.wallet != nil - } - } - - static func predicate(type: IdentityType) -> Predicate { - let typeString = type.rawValue - return #Predicate { identity in - identity.identityType == typeString - } - } - - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { identity in - identity.lastSyncedAt == nil || identity.lastSyncedAt! < date - } - } - - static func predicate(network: Network) -> Predicate { - let target = network.rawValue - return #Predicate { identity in - identity.networkRaw == target - } - } - - static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { - let target = network.rawValue - return #Predicate { identity in - identity.wallet != nil && identity.networkRaw == target - } - } - - static func fetch( - in context: ModelContext, - identityId: Data - ) -> DashSchemaV4.PersistentIdentity? { - let target = identityId - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.identityId == target } - ) - return try? context.fetch(descriptor).first - } -} - -extension DashSchemaV4.PersistentIdentity { - @discardableResult - static func updateBalance( - in context: ModelContext, - identityId: Data, - balance: UInt64 - ) -> Bool { - guard let row = fetch(in: context, identityId: identityId) else { return false } - row.balance = Int64(bitPattern: balance) - row.lastUpdated = Date() - return true - } - - @discardableResult - static func updateDpnsName( - in context: ModelContext, - identityId: Data, - dpnsName: String? - ) -> Bool { - guard let row = fetch(in: context, identityId: identityId) else { return false } - row.dpnsName = dpnsName - row.lastUpdated = Date() - return true - } - - @discardableResult - static func updateMainDpnsName( - in context: ModelContext, - identityId: Data, - mainDpnsName: String? - ) -> Bool { - guard let row = fetch(in: context, identityId: identityId) else { return false } - row.mainDpnsName = mainDpnsName - row.lastUpdated = Date() - return true - } - - @discardableResult - static func remove( - in context: ModelContext, - identityId: Data - ) -> Bool { - guard let row = fetch(in: context, identityId: identityId) else { return false } - context.delete(row) - return true - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift deleted file mode 100644 index 9b93607a0d4..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentIndex.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentIndex` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentIndex { - @Attribute(.unique) var id: Data - var contractId: Data - var documentTypeName: String - var name: String - - var unique: Bool - var nullSearchable: Bool - var contested: Bool - - var countable: String? - var rangeCountable: Bool = false - var summable: String? - var rangeSummable: Bool = false - var averageable: String? - var rangeAverageable: Bool = false - - var rankedCountable: Bool = false - var rankedSummable: Bool = false - var rankedAverageable: Bool = false - - var terminal: String? - - var preallocated: Bool = false - - var timeRangeJSON: Data? - - var propertiesJSON: Data - - var contestedDetailsJSON: Data? - - var createdAt: Date - - var documentType: PersistentDocumentType? - - init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { - var idData = contractId - idData.append(documentTypeName.data(using: .utf8) ?? Data()) - idData.append(name.data(using: .utf8) ?? Data()) - self.id = idData - - self.contractId = contractId - self.documentTypeName = documentTypeName - self.name = name - self.unique = false - self.nullSearchable = false - self.contested = false - - if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { - self.propertiesJSON = jsonData - } else { - self.propertiesJSON = Data() - } - - self.createdAt = Date() - } - } -} - -extension DashSchemaV4.PersistentIndex { - var properties: [String]? { - try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] - } - - var contestedDetails: [String: Any]? { - guard let data = contestedDetailsJSON else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } - - var timeRange: [String: Any]? { - guard let data = timeRangeJSON else { return nil } - return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift deleted file mode 100644 index 71cc5bb3f33..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentInvitation.swift +++ /dev/null @@ -1,73 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentInvitation` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentInvitation { - #Index([\.walletId]) - - @Attribute(.unique) var outPointHex: String - - var rawOutPoint: Data - - var walletId: Data - - var fundingIndexRaw: Int - - var amountDuffs: Int64 - - var expiryUnix: Int - - var createdAtSecs: Int - - var hasInviter: Bool - - var statusRaw: Int - - var reclaimInFlight: Bool = false - - var createdAt: Date - var updatedAt: Date - - init( - outPointHex: String, - rawOutPoint: Data, - walletId: Data, - fundingIndexRaw: Int, - amountDuffs: Int64, - expiryUnix: Int, - createdAtSecs: Int, - hasInviter: Bool, - statusRaw: Int, - reclaimInFlight: Bool = false - ) { - self.outPointHex = outPointHex - self.rawOutPoint = rawOutPoint - self.walletId = walletId - self.fundingIndexRaw = fundingIndexRaw - self.amountDuffs = amountDuffs - self.expiryUnix = expiryUnix - self.createdAtSecs = createdAtSecs - self.hasInviter = hasInviter - self.statusRaw = statusRaw - self.reclaimInFlight = reclaimInFlight - self.createdAt = Date() - self.updatedAt = Date() - } - } -} - -extension DashSchemaV4.PersistentInvitation { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in - entry.walletId == walletId - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift deleted file mode 100644 index b42f37707f7..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentKeyword.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentKeyword` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentKeyword { - @Attribute(.unique) var id: String - var keyword: String - var contractId: String - - var dataContract: PersistentDataContract? - - init(keyword: String, contractId: String) { - self.id = "\(contractId)_\(keyword)" - self.keyword = keyword - self.contractId = contractId - } - } -} - -extension DashSchemaV4.PersistentKeyword { - static func predicate(keyword: String) -> Predicate { - #Predicate { item in - item.keyword.localizedStandardContains(keyword) - } - } - - static func predicate(contractId: String) -> Predicate { - #Predicate { item in - item.contractId == contractId - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift deleted file mode 100644 index bd028d65783..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentMasternode.swift +++ /dev/null @@ -1,185 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentMasternode` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentMasternode { - #Unique([\.walletId, \.proTxHash]) - - var walletId: Data - var proTxHash: Data - var registrationTxid: Data - - var serviceAddress: String? - var isEvonode: Bool - - var ownerKeyHash: Data? - var votingKeyHash: Data? - var ownerAddress: String? - var votingAddress: String? - var operatorPublicKey: Data? - var platformNodeId: Data? - var payoutAddress: String? - var operatorPseudoAddress: String? - var platformNodeAddress: String? - - var ownerInWallet: Bool = false - var ownerAccountType: UInt8 = 0 - var ownerKeyIndex: UInt32 = 0 - var votingInWallet: Bool = false - var votingAccountType: UInt8 = 0 - var votingKeyIndex: UInt32 = 0 - var operatorInWallet: Bool = false - var operatorAccountType: UInt8 = 0 - var operatorKeyIndex: UInt32 = 0 - var platformInWallet: Bool = false - var platformAccountType: UInt8 = 0 - var platformKeyIndex: UInt32 = 0 - - var collateralTxid: Data? - var collateralVout: UInt32 - - var revoked: Bool - var revocationReason: UInt16 - var statusRaw: UInt8 = 3 - - var registrationHeight: UInt32 - var hasRegistration: Bool - var txCount: UInt32 - - var orderIndex: UInt32 - var typeIndex: UInt32 = 0 - - var createdAt: Date - var lastUpdated: Date - - init( - walletId: Data, - proTxHash: Data, - registrationTxid: Data, - serviceAddress: String? = nil, - isEvonode: Bool = false, - ownerKeyHash: Data? = nil, - votingKeyHash: Data? = nil, - ownerAddress: String? = nil, - votingAddress: String? = nil, - operatorPublicKey: Data? = nil, - platformNodeId: Data? = nil, - payoutAddress: String? = nil, - collateralTxid: Data? = nil, - collateralVout: UInt32 = 0, - revoked: Bool = false, - revocationReason: UInt16 = 0, - statusRaw: UInt8 = 3, - registrationHeight: UInt32 = 0, - hasRegistration: Bool = false, - txCount: UInt32 = 0, - orderIndex: UInt32 = 0, - typeIndex: UInt32 = 0 - ) { - self.walletId = walletId - self.proTxHash = proTxHash - self.registrationTxid = registrationTxid - self.serviceAddress = serviceAddress - self.isEvonode = isEvonode - self.ownerKeyHash = ownerKeyHash - self.votingKeyHash = votingKeyHash - self.ownerAddress = ownerAddress - self.votingAddress = votingAddress - self.operatorPublicKey = operatorPublicKey - self.platformNodeId = platformNodeId - self.payoutAddress = payoutAddress - self.collateralTxid = collateralTxid - self.collateralVout = collateralVout - self.revoked = revoked - self.revocationReason = revocationReason - self.statusRaw = statusRaw - self.registrationHeight = registrationHeight - self.hasRegistration = hasRegistration - self.txCount = txCount - self.orderIndex = orderIndex - self.typeIndex = typeIndex - self.createdAt = Date() - self.lastUpdated = Date() - } - - var proTxHashHex: String { - proTxHash.reversed().map { String(format: "%02x", $0) }.joined() - } - - var proTxHashShort: String { - let hex = proTxHashHex - guard hex.count >= 12 else { return hex } - return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" - } - - var ownerKeyHashHex: String? { - ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - var votingKeyHashHex: String? { - votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - static func providerAccountTypeName(_ tag: UInt8) -> String { - switch tag { - case 8: return "ProviderVotingKeys" - case 9: return "ProviderOwnerKeys" - case 10: return "ProviderOperatorKeys" - case 11: return "ProviderPlatformKeys" - default: return "Unknown(\(tag))" - } - } - - static func keyOwnershipLabel( - inWallet: Bool, - accountType: UInt8, - index: UInt32 - ) -> String { - inWallet - ? "\(providerAccountTypeName(accountType)) #\(index)" - : "not in this wallet" - } - - var operatorPublicKeyHex: String? { - operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - var platformNodeIdHex: String? { - platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - var collateralDisplay: String? { - guard let txid = collateralTxid else { return nil } - let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() - return "\(hex):\(collateralVout)" - } - - var displayNumber: Int { - Int(typeIndex) - } - - var typeName: String { - isEvonode ? "Evonode" : "Masternode" - } - - var displayTitle: String { - "\(typeName) \(displayNumber)" - } - - var status: MasternodeStatus { - MasternodeStatus(rawValue: statusRaw) ?? .unknown - } - - var statusName: String { - status.displayName - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift deleted file mode 100644 index 725f19910fd..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPendingInput.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentPendingInput` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentPendingInput { - #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) - var outpoint: Data - - var inputIndex: UInt32 - - var spendingTxid: Data - - var spendingTransaction: PersistentTransaction? - - var walletId: Data - - var createdAt: Date - - var isSweptTombstone: Bool = false - - var winnerMinedHeight: UInt32? - - init( - outpoint: Data, - inputIndex: UInt32, - spendingTxid: Data, - spendingTransaction: PersistentTransaction?, - walletId: Data - ) { - self.outpoint = outpoint - self.inputIndex = inputIndex - self.spendingTxid = spendingTxid - self.spendingTransaction = spendingTransaction - self.walletId = walletId - self.createdAt = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift deleted file mode 100644 index 90998081e7b..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddress.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentPlatformAddress` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentPlatformAddress { - #Index([\.walletId]) - - @Attribute(.unique) var address: String - var addressType: UInt8 - @Attribute(.unique) var addressHash: Data - var publicKey: Data - var accountIndex: UInt32 - var addressIndex: UInt32 - var derivationPath: String - var isUsed: Bool - var balance: UInt64 - var nonce: UInt32 - var firstSeenHeight: UInt32 - var lastSeenHeight: UInt64 - var walletId: Data - var createdAt: Date - var lastUpdated: Date - - var account: PersistentAccount? - - init( - address: String, - addressType: UInt8, - addressHash: Data, - publicKey: Data = Data(), - accountIndex: UInt32, - addressIndex: UInt32, - derivationPath: String, - isUsed: Bool = false, - balance: UInt64 = 0, - nonce: UInt32 = 0, - walletId: Data - ) { - self.address = address - self.addressType = addressType - self.addressHash = addressHash - self.publicKey = publicKey - self.accountIndex = accountIndex - self.addressIndex = addressIndex - self.derivationPath = derivationPath - self.isUsed = isUsed - self.balance = balance - self.nonce = nonce - self.firstSeenHeight = 0 - self.lastSeenHeight = 0 - self.walletId = walletId - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} - -extension DashSchemaV4.PersistentPlatformAddress { - static func predicate(walletId: Data) -> Predicate { - #Predicate { entry in - entry.walletId == walletId - } - } - - static var nonZeroBalancesPredicate: Predicate { - #Predicate { entry in - entry.balance > 0 - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift deleted file mode 100644 index fbc04ff24cf..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPlatformAddressesSyncState.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentPlatformAddressesSyncState { - @Attribute(.unique) var walletId: Data - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - var syncHeight: UInt64 - var syncTimestamp: UInt64 - var lastKnownRecentBlock: UInt64 - var lastUpdated: Date - - init( - walletId: Data, - network: Network, - syncHeight: UInt64, - syncTimestamp: UInt64, - lastKnownRecentBlock: UInt64 - ) { - self.walletId = walletId - self.networkRaw = network.rawValue - self.syncHeight = syncHeight - self.syncTimestamp = syncTimestamp - self.lastKnownRecentBlock = lastKnownRecentBlock - self.lastUpdated = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift deleted file mode 100644 index 233efafe6bd..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentProperty.swift +++ /dev/null @@ -1,55 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentProperty` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentProperty { - @Attribute(.unique) var id: Data - var contractId: Data - var documentTypeName: String - var name: String - - var type: String - var format: String? - var contentMediaType: String? - var byteArray: Bool - var minItems: Int? - var maxItems: Int? - var pattern: String? - var minLength: Int? - var maxLength: Int? - var minValue: Int? - var maxValue: Int? - var fieldDescription: String? - - var transient: Bool - var isRequired: Bool - - var createdAt: Date - - var documentType: PersistentDocumentType? - - init(contractId: Data, documentTypeName: String, name: String, type: String) { - var idData = contractId - idData.append(documentTypeName.data(using: .utf8) ?? Data()) - idData.append(name.data(using: .utf8) ?? Data()) - self.id = idData - - self.contractId = contractId - self.documentTypeName = documentTypeName - self.name = name - self.type = type - self.byteArray = false - self.transient = false - self.isRequired = false - self.createdAt = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift deleted file mode 100644 index 68c9d28cb32..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentPublicKey.swift +++ /dev/null @@ -1,171 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentPublicKey` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentPublicKey { - var keyId: Int32 - var purpose: String - var securityLevel: String - var keyType: String - var readOnly: Bool - var disabledAt: Int64? - - var publicKeyData: Data - - var contractBoundsData: Data? - - var contractBoundsDocumentTypeName: String? - - var privateKeyKeychainIdentifier: String? - - var walletId: Data? - - var identityDerivationPath: String? - - var identityId: String - var createdAt: Date - var lastAccessed: Date? - - @Relationship(inverse: \PersistentIdentity.publicKeys) - var identity: PersistentIdentity? - - init( - keyId: Int32, - purpose: KeyPurpose, - securityLevel: SecurityLevel, - keyType: KeyType, - publicKeyData: Data, - readOnly: Bool = false, - disabledAt: Int64? = nil, - contractBounds: [Data]? = nil, - contractBoundsDocumentTypeName: String? = nil, - identityId: String - ) { - self.keyId = keyId - self.purpose = String(purpose.rawValue) - self.securityLevel = String(securityLevel.rawValue) - self.keyType = String(keyType.rawValue) - self.publicKeyData = publicKeyData - self.readOnly = readOnly - self.disabledAt = disabledAt - if let contractBounds = contractBounds { - self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) - } else { - self.contractBoundsData = nil - } - self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName - self.identityId = identityId - self.createdAt = Date() - } - - var contractBounds: [Data]? { - get { - guard let data = contractBoundsData, - let json = try? JSONSerialization.jsonObject(with: data), - let strings = json as? [String] else { - return nil - } - return strings.compactMap { Data(base64Encoded: $0) } - } - set { - contractBoundsDocumentTypeName = nil - if let newValue = newValue { - contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) - } else { - contractBoundsData = nil - } - } - } - - var purposeEnum: KeyPurpose? { - guard let purposeInt = UInt8(purpose) else { return nil } - return KeyPurpose(rawValue: purposeInt) - } - - var securityLevelEnum: SecurityLevel? { - guard let levelInt = UInt8(securityLevel) else { return nil } - return SecurityLevel(rawValue: levelInt) - } - - var keyTypeEnum: KeyType? { - guard let typeInt = UInt8(keyType) else { return nil } - return KeyType(rawValue: typeInt) - } - - var isDisabled: Bool { - disabledAt != nil - } - - var hasPrivateKeyIdentifier: Bool { - privateKeyKeychainIdentifier != nil - } - } -} - -extension DashSchemaV4.PersistentPublicKey { - func toIdentityPublicKey() -> IdentityPublicKey? { - guard let purpose = purposeEnum, - let securityLevel = securityLevelEnum, - let keyType = keyTypeEnum else { - return nil - } - - let bounds: ContractBounds? - if let id = contractBounds?.first, id.count == 32 { - if let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { - bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) - } else { - bounds = .singleContract(id: id) - } - } else { - bounds = nil - } - - return IdentityPublicKey( - id: KeyID(keyId), - purpose: purpose, - securityLevel: securityLevel, - contractBounds: bounds, - keyType: keyType, - readOnly: readOnly, - data: publicKeyData, - disabledAt: disabledAt.map { TimestampMillis($0) } - ) - } - - static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaV4.PersistentPublicKey? { - let boundsIds: [Data]? - let docTypeName: String? - switch publicKey.contractBounds { - case .singleContract(let id): - boundsIds = [id] - docTypeName = nil - case .singleContractDocumentType(let id, let name): - boundsIds = [id] - docTypeName = name - case .none: - boundsIds = nil - docTypeName = nil - } - return DashSchemaV4.PersistentPublicKey( - keyId: Int32(publicKey.id), - purpose: publicKey.purpose, - securityLevel: publicKey.securityLevel, - keyType: publicKey.keyType, - publicKeyData: publicKey.data, - readOnly: publicKey.readOnly, - disabledAt: publicKey.disabledAt.map { Int64($0) }, - contractBounds: boundsIds, - contractBoundsDocumentTypeName: docTypeName, - identityId: identityId - ) - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift deleted file mode 100644 index e916eb9174a..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedActivity.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentShieldedActivity` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentShieldedActivity { - #Unique([\.walletId, \.accountIndex, \.entryId]) - #Index([\.walletId, \.accountIndex]) - - var walletId: Data - var accountIndex: UInt32 - var entryId: Data - - var kindTag: Int - var direction: Int - var status: Int - - var amount: UInt64 - var fee: UInt64 - var hasFee: Bool - var blockHeight: UInt64 - var hasBlockHeight: Bool - var createdAtMs: UInt64 - - var minNotePosition: UInt64 = 0 - var hasMinNotePosition: Bool = false - - var identityId: Data - var counterparty: Data - var memo: Data - var noteCmxs: Data - var spentNullifiers: Data - - var createdAt: Date - var lastUpdated: Date - - init( - walletId: Data, - accountIndex: UInt32, - entryId: Data, - kindTag: Int, - direction: Int, - status: Int, - amount: UInt64, - fee: UInt64, - hasFee: Bool, - blockHeight: UInt64, - hasBlockHeight: Bool, - createdAtMs: UInt64, - minNotePosition: UInt64 = 0, - hasMinNotePosition: Bool = false, - identityId: Data, - counterparty: Data, - memo: Data, - noteCmxs: Data, - spentNullifiers: Data - ) { - self.walletId = walletId - self.accountIndex = accountIndex - self.entryId = entryId - self.kindTag = kindTag - self.direction = direction - self.status = status - self.amount = amount - self.fee = fee - self.hasFee = hasFee - self.blockHeight = blockHeight - self.hasBlockHeight = hasBlockHeight - self.createdAtMs = createdAtMs - self.minNotePosition = minNotePosition - self.hasMinNotePosition = hasMinNotePosition - self.identityId = identityId - self.counterparty = counterparty - self.memo = memo - self.noteCmxs = noteCmxs - self.spentNullifiers = spentNullifiers - let now = Date() - self.createdAt = now - self.lastUpdated = now - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift deleted file mode 100644 index 2646b312466..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedNote.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentShieldedNote` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentShieldedNote { - #Index([\.walletId, \.accountIndex]) - - var walletId: Data - var accountIndex: UInt32 - var position: UInt64 - var cmx: Data - @Attribute(.unique) var nullifier: Data - var blockHeight: UInt64 - var isSpent: Bool - var value: UInt64 - var noteData: Data - - var createdAt: Date - var lastUpdated: Date - - init( - walletId: Data, - accountIndex: UInt32, - position: UInt64, - cmx: Data, - nullifier: Data, - blockHeight: UInt64, - isSpent: Bool, - value: UInt64, - noteData: Data - ) { - self.walletId = walletId - self.accountIndex = accountIndex - self.position = position - self.cmx = cmx - self.nullifier = nullifier - self.blockHeight = blockHeight - self.isSpent = isSpent - self.value = value - self.noteData = noteData - let now = Date() - self.createdAt = now - self.lastUpdated = now - } - } -} - -extension DashSchemaV4.PersistentShieldedNote { - static func unspentPredicate(walletId: Data) -> Predicate { - #Predicate { - $0.walletId == walletId && $0.isSpent == false - } - } - - static var unspentPredicate: Predicate { - #Predicate { $0.isSpent == false } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift deleted file mode 100644 index 5dd20e6b091..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedOutgoingNote.swift +++ /dev/null @@ -1,49 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentShieldedOutgoingNote { - #Unique([\.walletId, \.accountIndex, \.cmx]) - #Index([\.walletId, \.accountIndex]) - - var walletId: Data - var accountIndex: UInt32 - var cmx: Data - var recipient: Data - var value: UInt64 - var memo: Data - var blockHeight: UInt64 - - var createdAt: Date - var lastUpdated: Date - - init( - walletId: Data, - accountIndex: UInt32, - cmx: Data, - recipient: Data, - value: UInt64, - memo: Data, - blockHeight: UInt64 - ) { - self.walletId = walletId - self.accountIndex = accountIndex - self.cmx = cmx - self.recipient = recipient - self.value = value - self.memo = memo - self.blockHeight = blockHeight - let now = Date() - self.createdAt = now - self.lastUpdated = now - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift deleted file mode 100644 index 6c520c0f965..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedSyncState.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentShieldedSyncState` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentShieldedSyncState { - #Unique([\.walletId, \.accountIndex]) - #Index([\.walletId]) - - var walletId: Data - var accountIndex: UInt32 - var lastSyncedIndex: UInt64 - - var lastUpdated: Date - - init( - walletId: Data, - accountIndex: UInt32, - lastSyncedIndex: UInt64 = 0 - ) { - self.walletId = walletId - self.accountIndex = accountIndex - self.lastSyncedIndex = lastSyncedIndex - self.lastUpdated = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift deleted file mode 100644 index a51d1e46f9f..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentShieldedViewingKey.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentShieldedViewingKey` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentShieldedViewingKey { - #Unique([\.walletId, \.accountIndex]) - #Index([\.walletId]) - - var walletId: Data - var accountIndex: UInt32 - var fvkBytes: Data - - var lastUpdated: Date - - init( - walletId: Data, - accountIndex: UInt32, - fvkBytes: Data - ) { - self.walletId = walletId - self.accountIndex = accountIndex - self.fvkBytes = fvkBytes - self.lastUpdated = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift deleted file mode 100644 index f06de9f8f3b..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentToken.swift +++ /dev/null @@ -1,376 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentToken` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentToken { - @Attribute(.unique) var id: Data - var contractId: Data - var position: Int - var name: String - - var baseSupply: String - var maxSupply: String? - var decimals: Int - - var localizations: [String: TokenLocalization]? - - var isPaused: Bool - var allowTransferToFrozenBalance: Bool - - var keepsTransferHistory: Bool - var keepsFreezingHistory: Bool - var keepsMintingHistory: Bool - var keepsBurningHistory: Bool - var keepsDirectPricingHistory: Bool - var keepsDirectPurchaseHistory: Bool - - var conventionsChangeRules: ChangeControlRules? - var maxSupplyChangeRules: ChangeControlRules? - var manualMintingRules: ChangeControlRules? - var manualBurningRules: ChangeControlRules? - var freezeRules: ChangeControlRules? - var unfreezeRules: ChangeControlRules? - var destroyFrozenFundsRules: ChangeControlRules? - var emergencyActionRules: ChangeControlRules? - - var perpetualDistribution: TokenPerpetualDistribution? - var preProgrammedDistribution: TokenPreProgrammedDistribution? - var newTokensDestinationIdentity: Data? - var mintingAllowChoosingDestination: Bool - var distributionChangeRules: TokenDistributionChangeRules? - - var tradeMode: TokenTradeMode - var tradeModeChangeRules: ChangeControlRules? - - var mainControlGroupPosition: Int? - var mainControlGroupCanBeModified: String? - - var tokenDescription: String? - - var createdAt: Date - var lastUpdatedAt: Date - - var dataContract: PersistentDataContract? - - @Relationship(deleteRule: .cascade) - var balances: [PersistentTokenBalance]? - - @Relationship(deleteRule: .cascade) - var historyEvents: [PersistentTokenHistoryEvent]? - - init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { - var idData = contractId - withUnsafeBytes(of: position.bigEndian) { bytes in - idData.append(contentsOf: bytes) - } - self.id = idData - - self.contractId = contractId - self.position = position - self.name = name - self.baseSupply = baseSupply - self.decimals = decimals - - self.isPaused = false - self.allowTransferToFrozenBalance = true - self.keepsTransferHistory = true - self.keepsFreezingHistory = true - self.keepsMintingHistory = true - self.keepsBurningHistory = true - self.keepsDirectPricingHistory = true - self.keepsDirectPurchaseHistory = true - self.mintingAllowChoosingDestination = true - self.tradeMode = TokenTradeMode.notTradeable - - self.createdAt = Date() - self.lastUpdatedAt = Date() - } - } -} - -extension DashSchemaV4.PersistentToken { - var displayName: String { - if let desc = tokenDescription, !desc.isEmpty { - return desc - } - return getSingularForm() ?? name - } - - var formattedBaseSupply: String { - Self.formatSupply(baseSupply, decimals: decimals) - } - - static func formatSupply(_ raw: String, decimals: Int) -> String { - guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { - return raw - } - let normalized = String(raw.drop(while: { $0 == "0" })) - let digits = normalized.isEmpty ? "0" : normalized - let scale = max(0, decimals) - let integer: String - var fraction = "" - if scale == 0 { - integer = digits - } else if digits.count <= scale { - integer = "0" - fraction = String(repeating: "0", count: scale - digits.count) + digits - } else { - let split = digits.index(digits.endIndex, offsetBy: -scale) - integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } - grouped.append(character) - } - grouped = String(grouped.reversed()) - return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" - } - - var contractIdBase58: String { - contractId.toBase58String() - } - - var canManuallyMint: Bool { - manualMintingRules != nil - } - - var canManuallyBurn: Bool { - manualBurningRules != nil - } - - var canFreeze: Bool { - freezeRules != nil - } - - var canUnfreeze: Bool { - unfreezeRules != nil - } - - var canDestroyFrozenFunds: Bool { - destroyFrozenFundsRules != nil - } - - var hasEmergencyActions: Bool { - emergencyActionRules != nil - } - - var canChangeMaxSupply: Bool { - maxSupplyChangeRules != nil - } - - var canChangeConventions: Bool { - conventionsChangeRules != nil - } - - var hasDistribution: Bool { - perpetualDistribution != nil || preProgrammedDistribution != nil - } - - var canChangeTradeMode: Bool { - tradeModeChangeRules != nil - } - - var keepsAnyHistory: Bool { - keepsTransferHistory || - keepsFreezingHistory || - keepsMintingHistory || - keepsBurningHistory || - keepsDirectPricingHistory || - keepsDirectPurchaseHistory - } - - var totalSupply: String { - guard let balances = balances, !balances.isEmpty else { return baseSupply } - return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) - } - - var totalFrozenBalance: String { - guard let balances = balances else { return "0" } - return Self.sumUnsignedBalances( - balances.lazy.filter(\.frozen).map(\.unsignedBalance) - ) - } - - var activeHolders: Int { - balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 - } - - private static func sumUnsignedBalances(_ values: S) -> String - where S.Element == UInt64 { - var digits: [UInt8] = [0] // little-endian decimal digits - - for value in values { - var carry = 0 - let addend = String(value).utf8.reversed().map { Int($0 - 48) } - let width = max(digits.count, addend.count) - if digits.count < width { - digits.append(contentsOf: repeatElement(0, count: width - digits.count)) - } - - for index in 0.. 0 { - digits.append(UInt8(carry % 10)) - carry /= 10 - } - } - - return String(digits.reversed().map { Character(String($0)) }) - } - - var hasMaxSupply: Bool { - maxSupply != nil - } - - var isTradeable: Bool { - tradeMode != .notTradeable - } - - var newTokensDestinationIdentityBase58: String? { - newTokensDestinationIdentity?.toBase58String() - } -} - -extension DashSchemaV4.PersistentToken { - func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { - if localizations == nil { - localizations = [:] - } - localizations?[languageCode] = DashSchemaV4.TokenLocalization( - singularForm: singularForm, - pluralForm: pluralForm, - description: description - ) - lastUpdatedAt = Date() - } - - func getSingularForm(languageCode: String = "en") -> String? { - return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm - } - - func getPluralForm(languageCode: String = "en") -> String? { - return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm - } -} - -extension DashSchemaV4.PersistentToken { - func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaV4.ChangeControlRules? { - switch type { - case .conventions: return conventionsChangeRules - case .maxSupply: return maxSupplyChangeRules - case .manualMinting: return manualMintingRules - case .manualBurning: return manualBurningRules - case .freeze: return freezeRules - case .unfreeze: return unfreezeRules - case .destroyFrozenFunds: return destroyFrozenFundsRules - case .emergencyAction: return emergencyActionRules - case .tradeMode: return tradeModeChangeRules - } - } - - func setChangeControlRules(_ rules: DashSchemaV4.ChangeControlRules, for type: ChangeControlRuleType) { - switch type { - case .conventions: conventionsChangeRules = rules - case .maxSupply: maxSupplyChangeRules = rules - case .manualMinting: manualMintingRules = rules - case .manualBurning: manualBurningRules = rules - case .freeze: freezeRules = rules - case .unfreeze: unfreezeRules = rules - case .destroyFrozenFunds: destroyFrozenFundsRules = rules - case .emergencyAction: emergencyActionRules = rules - case .tradeMode: tradeModeChangeRules = rules - } - - lastUpdatedAt = Date() - } -} - -extension DashSchemaV4.PersistentToken { - static func mintableTokensPredicate() -> Predicate { - #Predicate { token in - token.manualMintingRules != nil - } - } - - static func burnableTokensPredicate() -> Predicate { - #Predicate { token in - token.manualBurningRules != nil - } - } - - static func freezableTokensPredicate() -> Predicate { - #Predicate { token in - token.freezeRules != nil - } - } - - static func distributionTokensPredicate() -> Predicate { - #Predicate { token in - token.perpetualDistribution != nil || token.preProgrammedDistribution != nil - } - } - - static func pausedTokensPredicate() -> Predicate { - #Predicate { token in - token.isPaused == true - } - } - - static func tokensByContractPredicate(contractId: Data) -> Predicate { - #Predicate { token in - token.contractId == contractId - } - } - - static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { - switch rule { - case .manualMinting: - return #Predicate { token in - token.manualMintingRules != nil - } - case .manualBurning: - return #Predicate { token in - token.manualBurningRules != nil - } - case .freeze: - return #Predicate { token in - token.freezeRules != nil - } - case .unfreeze: - return #Predicate { token in - token.unfreezeRules != nil - } - case .destroyFrozenFunds: - return #Predicate { token in - token.destroyFrozenFundsRules != nil - } - case .emergencyAction: - return #Predicate { token in - token.emergencyActionRules != nil - } - case .conventions: - return #Predicate { token in - token.conventionsChangeRules != nil - } - case .maxSupply: - return #Predicate { token in - token.maxSupplyChangeRules != nil - } - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift deleted file mode 100644 index 31d7c67c1bc..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenBalance.swift +++ /dev/null @@ -1,198 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTokenBalance` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentTokenBalance { - #Index([\.networkRaw]) - - var tokenId: String - var identityId: Data - var balance: Int64 - var frozen: Bool - - var createdAt: Date - var lastUpdated: Date - var lastSyncedAt: Date? - - var tokenName: String? - var tokenSymbol: String? - var tokenDecimals: Int32? - - var networkRaw: UInt32 - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? - @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? - - init( - tokenId: String, - identityId: Data, - balance: Int64 = 0, - frozen: Bool = false, - tokenName: String? = nil, - tokenSymbol: String? = nil, - tokenDecimals: Int32? = nil, - network: Network - ) { - self.tokenId = tokenId - self.identityId = identityId - self.balance = balance - self.frozen = frozen - self.tokenName = tokenName - self.tokenSymbol = tokenSymbol - self.tokenDecimals = tokenDecimals - self.createdAt = Date() - self.lastUpdated = Date() - self.lastSyncedAt = nil - self.networkRaw = network.rawValue - } - - convenience init( - tokenId: String, - identityId: Data, - unsignedBalance: UInt64, - frozen: Bool = false, - tokenName: String? = nil, - tokenSymbol: String? = nil, - tokenDecimals: Int32? = nil, - network: Network - ) { - self.init( - tokenId: tokenId, - identityId: identityId, - balance: Int64(bitPattern: unsignedBalance), - frozen: frozen, - tokenName: tokenName, - tokenSymbol: tokenSymbol, - tokenDecimals: tokenDecimals, - network: network - ) - } - - var unsignedBalance: UInt64 { - get { UInt64(bitPattern: balance) } - set { balance = Int64(bitPattern: newValue) } - } - - var formattedBalance: String { - let decimals: Int - if let tokenDecimals { - decimals = Int(tokenDecimals) - } else if let tokenDecimals = token?.decimals { - decimals = tokenDecimals - } else { - return "\(unsignedBalance)" - } - - guard decimals > 0 else { return String(unsignedBalance) } - - let digits = String(unsignedBalance) - let scale = decimals - if digits.count <= scale { - return "0." + String(repeating: "0", count: scale - digits.count) + digits - } - let split = digits.index(digits.endIndex, offsetBy: -scale) - return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { - return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) - } -} - -extension DashSchemaV4.PersistentTokenBalance { - static func predicate(tokenId: String, identityId: Data) -> Predicate { - #Predicate { balance in - balance.tokenId == tokenId && balance.identityId == identityId - } - } - - static func predicate(identityId: Data) -> Predicate { - #Predicate { balance in - balance.identityId == identityId - } - } - - static func predicate(tokenId: String) -> Predicate { - #Predicate { balance in - balance.tokenId == tokenId - } - } - - static var nonZeroBalancesPredicate: Predicate { - #Predicate { balance in - balance.balance != 0 - } - } - - static var frozenBalancesPredicate: Predicate { - #Predicate { balance in - balance.frozen == true - } - } - - static func needsSyncPredicate(olderThan date: Date) -> Predicate { - #Predicate { balance in - balance.lastSyncedAt == nil || balance.lastSyncedAt! < date - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift deleted file mode 100644 index 5972942e7fb..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTokenHistoryEvent.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTokenHistoryEvent` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentTokenHistoryEvent { - @Attribute(.unique) var id: UUID - - var eventType: String - var transactionId: Data? - var blockHeight: Int64? - var coreBlockHeight: Int64? - - var fromIdentity: Data? - var toIdentity: Data? - var performedByIdentity: Data - - var amount: String? - var balanceBefore: String? - var balanceAfter: String? - - var additionalDataJSON: Data? - - var eventDescription: String? - - var createdAt: Date - var eventTimestamp: Date - - @Relationship(inverse: \PersistentToken.historyEvents) - var token: PersistentToken? - - init( - eventType: TokenEventType, - performedByIdentity: Data, - eventTimestamp: Date = Date() - ) { - self.id = UUID() - self.eventType = eventType.rawValue - self.performedByIdentity = performedByIdentity - self.eventTimestamp = eventTimestamp - self.createdAt = Date() - } - - var eventTypeEnum: TokenEventType { - TokenEventType(rawValue: eventType) ?? .unknown - } - - var fromIdentityBase58: String? { - fromIdentity?.toBase58String() - } - - var toIdentityBase58: String? { - toIdentity?.toBase58String() - } - - var performedByIdentityBase58: String { - performedByIdentity.toBase58String() - } - - var displayTitle: String { - switch eventTypeEnum { - case .mint: - return "Minted \(formattedAmount)" - case .burn: - return "Burned \(formattedAmount)" - case .transfer: - return "Transfer \(formattedAmount)" - case .freeze: - return "Frozen \(formattedAmount)" - case .unfreeze: - return "Unfrozen \(formattedAmount)" - case .destroyFrozenFunds: - return "Destroyed Frozen Funds \(formattedAmount)" - case .configUpdate: - return "Configuration Updated" - case .emergencyAction: - return "Emergency Action" - case .perpetualDistribution: - return "Perpetual Distribution \(formattedAmount)" - case .preProgrammedRelease: - return "Pre-programmed Release \(formattedAmount)" - case .directPricing: - return "Direct Pricing Updated" - case .directPurchase: - return "Direct Purchase \(formattedAmount)" - case .unknown: - return "Unknown Event" - } - } - - private var formattedAmount: String { - guard let amount = amount else { return "" } - return amount - } - - func setAdditionalData(_ data: [String: Any]) { - additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) - } - - func getAdditionalData() -> [String: Any]? { - guard let data = additionalDataJSON else { return nil } - return try? JSONSerialization.jsonObject(with: data) as? [String: Any] - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift deleted file mode 100644 index 4a2e857dbd9..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTrackedMasternode.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTrackedMasternode` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @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/DashSchemaV4+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift deleted file mode 100644 index 9550581b615..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTransaction.swift +++ /dev/null @@ -1,167 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTransaction` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentTransaction { - #Index([\.firstSeen]) - - @Attribute(.unique) var txid: Data - var transactionData: Data - var context: UInt32 - var blockHeight: UInt32 - var blockHash: Data? - var blockTimestamp: UInt32 - var blockPosition: UInt32 = 0 - var hasBlockPosition: Bool = false - var direction: UInt32 - var transactionType: String - var transactionTypeKind: UInt8 = 0xFF - var netAmount: Int64 - var fee: UInt64? - var label: String - var firstSeen: UInt64 - - var providerServiceAddress: String? = nil - var providerProTxHash: Data? = nil - var providerCollateralTxid: Data? = nil - var providerCollateralVout: UInt32 = 0 - var providerOwnerKeyHash: Data? = nil - var providerVotingKeyHash: Data? = nil - - var createdAt: Date - var lastUpdated: Date - - @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) - var outputs: [PersistentTxo] = [] - - @Relationship(inverse: \PersistentTxo.spendingTransaction) - var inputs: [PersistentTxo] = [] - - @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) - var pendingInputs: [PersistentPendingInput] = [] - - @Relationship(inverse: \PersistentAccount.involvedTransactions) - var involvedAccounts: [PersistentAccount] = [] - - init( - txid: Data, - transactionData: Data, - context: UInt32 = 0, - blockHeight: UInt32 = 0, - direction: UInt32 = 0, - transactionType: String = "Standard", - netAmount: Int64 = 0, - firstSeen: UInt64 = 0 - ) { - self.txid = txid - self.transactionData = transactionData - self.context = context - self.blockHeight = blockHeight - self.blockTimestamp = 0 - self.direction = direction - self.transactionType = transactionType - self.netAmount = netAmount - self.firstSeen = firstSeen - self.label = "" - self.createdAt = Date() - self.lastUpdated = Date() - } - - var txidHex: String { - txid.reversed().map { String(format: "%02x", $0) }.joined() - } - - var contextName: String { - switch context { - case 0: return "Mempool" - case 1: return "InstantSend" - case 2: return "In Block" - case 3: return "Chain Locked" - default: return "Unknown" - } - } - - var directionName: String { - switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" - default: return "Unknown" - } - } - - var typedKind: TransactionTypeKind? { - TransactionTypeKind(rawValue: transactionTypeKind) - } - - var isAssetLock: Bool { - typedKind == .assetLock - } - - var isAssetUnlock: Bool { - typedKind == .assetUnlock - } - - var isProviderRegistration: Bool { - typedKind == .providerRegistration - } - - var isProviderUpdateService: Bool { - typedKind == .providerUpdateService - } - - var providerProTxHashHex: String? { - providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } - } - - var providerCollateralDisplay: String? { - guard let txid = providerCollateralTxid else { return nil } - let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() - return "\(hex):\(providerCollateralVout)" - } - - var providerOwnerKeyHashHex: String? { - providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - var providerVotingKeyHashHex: String? { - providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } - } - - var isProviderSpecial: Bool { - providerSpecialName != nil - } - - var providerSpecialName: String? { - switch typedKind { - case .providerRegistration: return "Provider Registration" - case .providerUpdateRegistrar: return "Provider Update Registrar" - case .providerUpdateService: return "Provider Update Service" - case .providerUpdateRevocation: return "Provider Update Revocation" - default: return nil - } - } - - var displayDirection: String { - if isAssetLock { return "Asset Lock" } - if isAssetUnlock { return "Asset Unlock" } - if let name = providerSpecialName { return name } - return directionName - } - - var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift deleted file mode 100644 index 548d513f9b2..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentTxo.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentTxo` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentTxo { - #Index([\.walletId]) - - @Attribute(.unique) var outpoint: Data - var vout: UInt32 - var amount: UInt64 - var address: String - var scriptPubKey: Data - var height: UInt32 - var isCoinbase: Bool - var isConfirmed: Bool - var isInstantLocked: Bool - var isLocked: Bool - var isSpent: Bool - var createdAt: Date - var lastUpdated: Date - - var walletId: Data = Data() - - var transaction: PersistentTransaction? - - var spendingTransaction: PersistentTransaction? - - var supersededByTxid: Data? - - var spendingInputIndex: UInt32? = nil - - var account: PersistentAccount? - - var coreAddress: PersistentCoreAddress? - - init( - transaction: PersistentTransaction, - vout: UInt32, - amount: UInt64, - address: String, - scriptPubKey: Data = Data(), - height: UInt32 = 0 - ) { - self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) - self.vout = vout - self.amount = amount - self.address = address - self.scriptPubKey = scriptPubKey - self.height = height - self.isCoinbase = false - self.isConfirmed = false - self.isInstantLocked = false - self.isLocked = false - self.isSpent = false - self.createdAt = Date() - self.lastUpdated = Date() - self.transaction = transaction - } - - static func makeOutpoint(txid: Data, vout: UInt32) -> Data { - var data = Data(capacity: 36) - data.append(txid) - var v = vout.littleEndian - withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } - return data - } - - var txid: Data { - if let transaction { - return transaction.txid - } - return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() - } - - var txidHex: String { - let rawTxid = txid - guard rawTxid.count == 32 else { return "" } - return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() - } - - var outpointHex: String { - let hex = txidHex - return hex.isEmpty ? "" : "\(hex):\(vout)" - } - - var formattedAmount: String { - let dash = Double(amount) / 100_000_000.0 - return String(format: "%.8f DASH", dash) - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift deleted file mode 100644 index 8301f4bc246..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWallet.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentWallet` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentWallet { - #Index([\.networkRaw], [\.walletGroupId]) - #Unique([\.walletId]) - - var walletId: Data - var walletGroupId: Data = Data() - var networkRaw: UInt32? - - var network: Network? { - get { - guard let raw = networkRaw else { return nil } - return Network(rawValue: raw) ?? .testnet - } - set { networkRaw = newValue?.rawValue } - } - var name: String? - var walletDescription: String? - var birthHeight: UInt32 - var syncedHeight: UInt32 - var lastSynced: UInt64 - var lastAppliedChainLockBytes: Data? - var lastAppliedChainLockHeight: UInt32? - var isImported: Bool = false - var seedBindingVerifiedMarker: String? - var createdAt: Date - var lastUpdated: Date - - @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) - var accounts: [PersistentAccount] - - @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) - var identities: [PersistentIdentity] - - init( - walletId: Data, - walletGroupId: Data = Data(), - network: Network? = nil, - name: String? = nil, - walletDescription: String? = nil, - birthHeight: UInt32 = 0, - syncedHeight: UInt32 = 0, - isImported: Bool = false - ) { - self.walletId = walletId - self.walletGroupId = walletGroupId - self.networkRaw = network?.rawValue - self.name = name - self.walletDescription = walletDescription - self.birthHeight = birthHeight - self.syncedHeight = syncedHeight - self.lastSynced = 0 - self.isImported = isImported - self.createdAt = Date() - self.lastUpdated = Date() - self.accounts = [] - self.identities = [] - } - } -} - -extension DashSchemaV4.PersistentWallet { - var label: String { - if let name = name, !name.isEmpty { - return name - } - let hex = walletId.prefix(4) - .map { String(format: "%02x", $0) } - .joined() - return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" - } -} - -extension DashSchemaV4.PersistentWallet { - static func predicate(walletId: Data) -> Predicate { - #Predicate { $0.walletId == walletId } - } - - static func predicate( - walletGroupId: Data - ) -> Predicate { - #Predicate { $0.walletGroupId == walletGroupId } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift deleted file mode 100644 index 66eeeabc41f..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+PersistentWalletManagerMetadata.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation -import SwiftData - -// `PersistentWalletManagerMetadata` exactly as schema DashSchemaV4 registered it, generated by -// scripts/freeze_schema_models.py from the live model at commit 787cac09e7. -// 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 DashSchemaV4 { - @Model - final class PersistentWalletManagerMetadata { - @Attribute(.unique) var networkRaw: UInt32 - var combinedSyncHeight: UInt32 - var combinedSyncBlockHash: Data? - var walletCount: Int - var createdAt: Date - var lastUpdated: Date - - var network: Network { - get { Network(rawValue: networkRaw) ?? .testnet } - set { networkRaw = newValue.rawValue } - } - - init(network: Network) { - self.networkRaw = network.rawValue - self.combinedSyncHeight = 0 - self.walletCount = 0 - self.createdAt = Date() - self.lastUpdated = Date() - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift deleted file mode 100644 index 7e6f1bb0c13..00000000000 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaV4+TokenTypes.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Foundation -import SwiftData - -// Inline value types exactly as schema DashSchemaV4 stored them, generated -// by scripts/freeze_schema_models.py from TokenTypes.swift -// at commit 787cac09e7. SwiftData expands a stored Codable struct into composite -// attributes of the owning entity, so these shapes are inputs to that -// version's checksum just like the model's own properties. Do not edit. -extension DashSchemaV4 { - struct ChangeControlRules: Codable, Equatable, Sendable { - var authorizedToMakeChange: String - var adminActionTakers: String - var changingAuthorizedActionTakersToNoOneAllowed: Bool - var changingAdminActionTakersToNoOneAllowed: Bool - var selfChangingAdminActionTakersAllowed: Bool - - init( - authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, - adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, - changingAuthorizedActionTakersToNoOneAllowed: Bool = false, - changingAdminActionTakersToNoOneAllowed: Bool = false, - selfChangingAdminActionTakersAllowed: Bool = false - ) { - self.authorizedToMakeChange = authorizedToMakeChange - self.adminActionTakers = adminActionTakers - self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed - self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed - self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed - } - - static func mostRestrictive() -> ChangeControlRules { - return ChangeControlRules() - } - - static func contractOwnerControlled() -> ChangeControlRules { - return ChangeControlRules( - authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, - adminActionTakers: AuthorizedActionTakers.noOne.rawValue, - selfChangingAdminActionTakersAllowed: true - ) - } - } - - enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { - case noOne = "NoOne" - case contractOwner = "ContractOwner" - case mainGroup = "MainGroup" - - static func identity(_ id: Data) -> String { - return "Identity:\(id.toBase58String())" - } - - static func group(_ position: Int) -> String { - return "Group:\(position)" - } - } - - struct TokenPerpetualDistribution: Codable, Equatable, Sendable { - var distributionType: String - var distributionRecipient: String - var enabled: Bool - var lastDistributionTime: Date? - var nextDistributionTime: Date? - - init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { - self.distributionType = "{}" - self.distributionRecipient = distributionRecipient - self.enabled = enabled - } - } - - struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { - var distributionSchedule: [DistributionEvent] - var currentEventIndex: Int - var totalDistributed: String - var remainingToDistribute: String - var isActive: Bool - var isPaused: Bool - var isCompleted: Bool - - init() { - self.distributionSchedule = [] - self.currentEventIndex = 0 - self.totalDistributed = "0" - self.remainingToDistribute = "0" - self.isActive = true - self.isPaused = false - self.isCompleted = false - } - } - - struct DistributionEvent: Codable, Equatable, Sendable { - var id: UUID - var triggerType: String - var triggerTime: Date? - var triggerBlock: Int64? - var triggerCondition: String? - var amount: String - var recipient: String - var description: String? - - init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { - self.id = UUID() - self.triggerType = "Time" - self.triggerTime = triggerTime - self.amount = amount - self.recipient = recipient - self.description = description - } - } - - struct TokenDistributionChangeRules: Codable, Equatable, Sendable { - var perpetualDistributionRules: ChangeControlRules? - var newTokensDestinationIdentityRules: ChangeControlRules? - var mintingAllowChoosingDestinationRules: ChangeControlRules? - var changeDirectPurchasePricingRules: ChangeControlRules? - - init( - perpetualDistributionRules: ChangeControlRules? = nil, - newTokensDestinationIdentityRules: ChangeControlRules? = nil, - mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, - changeDirectPurchasePricingRules: ChangeControlRules? = nil - ) { - self.perpetualDistributionRules = perpetualDistributionRules - self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules - self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules - self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules - } - } - - enum TokenTradeMode: String, CaseIterable, Codable, Sendable { - case notTradeable = "NotTradeable" - - var displayName: String { - switch self { - case .notTradeable: - return "Not Tradeable" - } - } - } - - struct TokenLocalization: Codable, Equatable, Sendable { - let singularForm: String - let pluralForm: String - let description: String? - - init(singularForm: String, pluralForm: String, description: String? = nil) { - self.singularForm = singularForm - self.pluralForm = pluralForm - self.description = description - } - } -} 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/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift index a2f85fc8b23..550781f65ee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPublicKey.swift @@ -62,7 +62,8 @@ public final class PersistentPublicKey { /// those fall back to the legacy inference, which never yields 3 /// because no writer could produce a group bound back then. See /// `effectiveContractBoundsKind`. Additive optional column, so - /// SwiftData's lightweight migration backfills `NULL` (schema V5). + /// SwiftData's lightweight migration backfills `NULL` when migrating + /// the accepted V1 baseline to V2. public var contractBoundsKind: Int? // MARK: - Private Key Reference (optional) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift index ef696034fdc..f0649429e26 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentToken.swift @@ -191,11 +191,10 @@ extension PersistentToken { /// contract's `serializedContract` already holds the whole contract JSON, /// distribution rules included, so the value is persisted with every /// contract the parser writes, and a new stored property here would move - /// this model's entity hash. That costs a schema version and a fixture - /// store (see `DashModelContainer.modelTypes` and - /// `DashModelMigrationTests`), which a display-only amount does not - /// justify. `perpetualDistribution` and `preProgrammedDistribution` - /// predate that discipline and kept their columns. + /// this model's entity hash. Keeping this value derived avoids a redundant + /// column and preserves compatibility with released snapshots. + /// `perpetualDistribution` and `preProgrammedDistribution` keep their + /// existing columns. /// /// Nil both when the token declares no such distribution and when the /// contract JSON cannot be read: a token row whose `dataContract` @@ -210,7 +209,9 @@ extension PersistentToken { public var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { guard let contract = dataContract else { return nil } return TokenOncePerIdentityDistributionCache.shared.distribution( - for: contract, + contractId: contract.id, + serializedContract: contract.serializedContract, + lastUpdated: contract.lastUpdated, position: position ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift index 004afdd1e90..7a894966fc8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/TokenOncePerIdentityDistributionCache.swift @@ -13,10 +13,9 @@ import Foundation /// would otherwise decode a full contract to learn nothing. This decodes once /// per distinct contract payload instead and answers the rest from memory. /// -/// A stored property on the model would have been the obvious place to keep -/// the answer, but `DashSchemaV5` is frozen: a new stored property moves -/// `PersistentToken`'s entity hash and costs a schema version. A process-wide -/// memo sidesteps the schema entirely. +/// The contract JSON already persists the answer. A process-wide memo +/// avoids adding a redundant column to the model graph or changing any +/// released schema. Stored values also let frozen model copies use this cache. /// /// Thread-safe: SwiftData rows are read from whichever actor owns their /// context, so the map is guarded by a lock rather than pinned to the main @@ -71,17 +70,18 @@ final class TokenOncePerIdentityDistributionCache: @unchecked Sendable { } /// The once-per-identity distribution declared by the token at - /// `position` of `contract`, or nil when it declares none (or when the + /// `position` of a contract payload, or nil when it declares none (or when the /// contract's JSON cannot be read at all). func distribution( - for contract: PersistentDataContract, + contractId: Data, + serializedContract: Data, + lastUpdated: Date, position: Int ) -> TokenOncePerIdentityDistribution? { - let serialized = contract.serializedContract let key = Key( - contractId: contract.id, - byteCount: serialized.count, - lastUpdated: contract.lastUpdated + contractId: contractId, + byteCount: serializedContract.count, + lastUpdated: lastUpdated ) return lock.withLock { @@ -91,7 +91,7 @@ final class TokenOncePerIdentityDistributionCache: @unchecked Sendable { // The decode runs under the lock so two callers racing on a cold // contract decode it once between them rather than twice each. - let parsed = Self.parseAllPositions(serialized) + let parsed = Self.parseAllPositions(serializedContract) decodes += 1 entries[key] = parsed insertionOrder.append(key) 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/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift index 29a2ab88705..f10a3c9e9c8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/OncePerIdentityClaimStore.swift @@ -26,11 +26,10 @@ protocol OncePerIdentityClaimRecording: OncePerIdentityClaimReading { /// `UserDefaults`-backed record of once-per-identity claims this app saw /// succeed, plus the ones Drive told us had already happened. /// -/// Deliberately not SwiftData: `DashSchemaV5` is frozen, and a new stored -/// property or model would cost a schema version for what is a local hint, -/// not protocol state. The hint is one-way, set and never cleared, which is -/// safe because the fact it caches cannot become false again: a spent claim -/// stays spent, and identity ids are not reused. +/// Kept in UserDefaults to avoid changing the shared SwiftData model graph +/// for a local hint rather than protocol state. The hint is one-way, set and +/// never cleared. This is safe because the fact it caches cannot become false +/// again: a spent claim stays spent, and identity ids are not reused. /// /// It is a hint, not an authority. An identity that claimed on another /// device is absent from it, and that claim is still caught the expensive diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift new file mode 100644 index 00000000000..e0c4e28ff6a --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -0,0 +1,796 @@ +import CoreData +import Darwin +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 (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) + let url = directory.appendingPathComponent("DashModel.store") + try FileManager.default.copyItem(at: source, to: url) + return (directory, 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) + 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 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 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) + 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 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 { + 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") + XCTAssertTrue(try operationDirectories(url).isEmpty, "A later successful open reclaims the retained backup") + } + } + + 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) + 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 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 + 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 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) + } + } + } + + 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 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) + 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") + } + + 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 { + // 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 + ticks += 1 + } + return (ticks, longestGap) + } + defer { heartbeat.cancel() } + let started = Date.timeIntervalSinceReferenceDate + 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 + 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) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 2c2708d4f81..e7b2182783a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -6,40 +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`, `dash-v4` by the build that registered V4, and `dash-v5` -/// by the build that last changed V5 before it shipped (the bounds kind -/// column), all 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 @@ -62,30 +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), - Fixture( - name: "dash-v5", version: DashSchemaV5.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", "5.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) @@ -120,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. @@ -215,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") + Self.fixtures.map { $0.version.versionIdentifier }, + Self.acceptedBaselineVersions, + "the accepted baseline must retain its existing fixture") for fixture in Self.fixtures { let (directory, url) = try copyFixture(fixture) @@ -295,29 +202,31 @@ 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 testMigrationPlanContainsBaselinePublishedAndLiveVersionsInOrder() { + let publishedVersions = DashReleasedSchemaRegistry.fixtures.map { + $0.version.versionIdentifier + } + let expected = Set( + Self.acceptedBaselineVersions + publishedVersions + [DashModelContainer.schema.version] + ).sorted() 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.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 @@ -332,101 +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") - } - - /// 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? + 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( - 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") + 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 @@ -520,171 +341,17 @@ 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() + func testV2AddsTrackedMasternodesToTheBaselineEntitySet() { XCTAssertEqual( - try migrated.mainContext.fetchCount( - FetchDescriptor()), - 1) - } - - /// The V3 -> V4 stage: a V3 store must migrate to V4 and read back with - /// the sweep columns backfilled to their "nothing swept yet" values. - /// Both versions register frozen graphs, so the row goes in as V3's - /// copy and comes out as V4's, 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]) - - // V4 registers its own frozen graph, so the read side is those - // types: the live models are schema V5's. - 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") + Set(Schema(versionedSchema: DashSchemaV2.self).entities.map(\.name)) + .subtracting(Schema(versionedSchema: DashSchemaV1.self).entities.map(\.name)), + ["PersistentTrackedMasternode"]) } - /// The whole chain from the oldest registered version, on the models this - /// V4 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. Every version here registers a frozen - /// graph, so the rows go in as V1's copies and come out as V4's: the - /// property the freeze exists to guarantee, pinned here where it - /// matters most. + /// The accepted V1 graph predates key limits. Its keys must arrive in + /// live V2 unlimited, then accept limits through the public accessors. @MainActor - func testV1StoreWithWalletTransactionAndCoinMigratesToV4() throws { + func testV1StoreMigratesToV2AndBackfillsTheKeyLimitColumns() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( @@ -692,12 +359,11 @@ final class DashModelMigrationTests: XCTestCase { 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 identityId = "FixtureIdentityBase58" let v1Schema = Schema(versionedSchema: DashSchemaV1.self) let v1Configuration = ModelConfiguration( - "DashChainMigrationTest", + "DashKeyLimitsMigrationTest", schema: v1Schema, url: storeURL, allowsSave: true, @@ -705,116 +371,35 @@ final class DashModelMigrationTests: XCTestCase { 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") - } - - /// The V4 -> V5 stage: a V4 store must migrate to V5 and read back with - /// the key usage-limit columns backfilled to "no limits", which is - /// exactly what a version 0 key is. V4 registers a frozen graph, so the - /// row goes in as V4's copy and comes out as the live one: the same - /// entity, three properties wider. - @MainActor - func testV4StoreMigratesToV5AndBackfillsTheKeyLimitColumns() 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 identityId = "FixtureIdentityBase58" - - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( - "DashKeyLimitsMigrationTest", - schema: v4Schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none) - var v4Container: ModelContainer? = try ModelContainer( - for: v4Schema, - configurations: [v4Configuration]) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 3, purpose: .authentication, securityLevel: .high, keyType: .ecdsaSecp256k1, publicKeyData: Data(repeating: 0x02, count: 33), identityId: identityId)) - try v4Container?.mainContext.save() - v4Container = nil + try v1Container?.mainContext.save() + v1Container = nil - let v5Schema = Schema(versionedSchema: DashSchemaV5.self) - let v5Configuration = ModelConfiguration( + let v2Schema = Schema(versionedSchema: DashSchemaV2.self) + let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", - schema: v5Schema, + schema: v2Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) let migrated = try ModelContainer( - for: v5Schema, + for: v2Schema, migrationPlan: DashMigrationPlan.self, - configurations: [v5Configuration]) + configurations: [v2Configuration]) let keys = try migrated.mainContext.fetch(FetchDescriptor()) - XCTAssertEqual(keys.count, 1, "the V4 key row must survive the migration") + XCTAssertEqual(keys.count, 1, "the V1 key row must survive the migration") let key = try XCTUnwrap(keys.first) XCTAssertEqual(key.keyId, 3) XCTAssertEqual(key.identityId, identityId) XCTAssertEqual(key.publicKeyData, Data(repeating: 0x02, count: 33)) - XCTAssertNil(key.totalBudget, "a key migrated from V4 carries no budget") + XCTAssertNil(key.totalBudget, "a key migrated from V1 carries no budget") XCTAssertNil(key.expiresAt, "nor an expiry; together, that is a version 0 key") XCTAssertFalse(key.hasLimits) @@ -830,12 +415,10 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(reread.hasLimits) } - /// The bounds half of the same stage: a V4 store's key rows arrive with - /// `contractBoundsKind` backfilled to NULL, which the model reads as - /// "legacy row, infer the variant the way V4 did", and the column is - /// writable on the migrated row. + /// Legacy contract bounds keep their inferred variant after V1 -> V2, + /// and the new discriminator can then represent contract groups. @MainActor - func testV4StoreMigratesToV5AndBackfillsTheContractBoundsKind() throws { + func testV1StoreMigratesToV2AndBackfillsTheContractBoundsKind() throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( @@ -845,17 +428,17 @@ final class DashModelMigrationTests: XCTestCase { let contractId = Data(repeating: 0x7C, count: 32) - let v4Schema = Schema(versionedSchema: DashSchemaV4.self) - let v4Configuration = ModelConfiguration( + let v1Schema = Schema(versionedSchema: DashSchemaV1.self) + let v1Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", - schema: v4Schema, + schema: v1Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) - var v4Container: ModelContainer? = try ModelContainer( - for: v4Schema, - configurations: [v4Configuration]) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + var v1Container: ModelContainer? = try ModelContainer( + for: v1Schema, + configurations: [v1Configuration]) + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 3, purpose: .authentication, securityLevel: .high, @@ -864,7 +447,7 @@ final class DashModelMigrationTests: XCTestCase { contractBounds: [contractId], contractBoundsDocumentTypeName: "contactRequest", identityId: "legacyDocTypeKey")) - v4Container?.mainContext.insert(DashSchemaV4.PersistentPublicKey( + v1Container?.mainContext.insert(DashSchemaV1.PersistentPublicKey( keyId: 4, purpose: .authentication, securityLevel: .high, @@ -872,24 +455,24 @@ final class DashModelMigrationTests: XCTestCase { publicKeyData: Data(repeating: 0x03, count: 33), contractBounds: [contractId], identityId: "legacyContractKey")) - try v4Container?.mainContext.save() - v4Container = nil + try v1Container?.mainContext.save() + v1Container = nil - let v5Schema = Schema(versionedSchema: DashSchemaV5.self) - let v5Configuration = ModelConfiguration( + let v2Schema = Schema(versionedSchema: DashSchemaV2.self) + let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", - schema: v5Schema, + schema: v2Schema, url: storeURL, allowsSave: true, cloudKitDatabase: .none) let migrated = try ModelContainer( - for: v5Schema, + for: v2Schema, migrationPlan: DashMigrationPlan.self, - configurations: [v5Configuration]) + configurations: [v2Configuration]) let rows = try migrated.mainContext.fetch( FetchDescriptor(sortBy: [SortDescriptor(\.keyId)])) - XCTAssertEqual(rows.map(\.keyId), [3, 4], "both V4 rows survive the migration") + XCTAssertEqual(rows.map(\.keyId), [3, 4], "both V1 rows survive the migration") XCTAssertEqual( rows.map(\.contractBoundsKind), [nil, nil], "a row written before the column backfills NULL") @@ -909,184 +492,14 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertEqual(reread[1].effectiveContractBoundsKind, 3) } - /// What makes the V4 -> V5 stage lightweight: the two versions name the - /// same entity set, and V5 only widens `PersistentPublicKey`. Also pins - /// that V4's frozen copy does NOT carry the three columns: one that came - /// back would silently change V4's checksum and strand every store the - /// V4 build wrote. - func testV4AndV5NameTheSameEntitySet() throws { - let v4 = Schema(versionedSchema: DashSchemaV4.self) - let v5 = Schema(versionedSchema: DashSchemaV5.self) - XCTAssertEqual( - v4.entities.map(\.name).sorted(), - v5.entities.map(\.name).sorted()) - - let key = try XCTUnwrap(v5.entities.first { $0.name == "PersistentPublicKey" }) - XCTAssertNotNil(key.attributesByName["totalBudget"]) - XCTAssertNotNil(key.attributesByName["expiresAt"]) - XCTAssertNotNil(key.attributesByName["contractBoundsKind"]) - - let frozenKey = try XCTUnwrap(v4.entities.first { $0.name == "PersistentPublicKey" }) - XCTAssertNil(frozenKey.attributesByName["totalBudget"]) - XCTAssertNil(frozenKey.attributesByName["expiresAt"]) - XCTAssertNil( - frozenKey.attributesByName["contractBoundsKind"], - "V4's frozen copy predates the column") - - // V5 widens that entity and nothing else. - for name in v5.entities.map(\.name) where name != "PersistentPublicKey" { - let live = try XCTUnwrap(v5.entities.first { $0.name == name }) - let frozen = try XCTUnwrap(v4.entities.first { $0.name == name }) - XCTAssertEqual( - live.attributesByName.keys.sorted(), - frozen.attributesByName.keys.sorted(), - "V5 adds no column to \(name)") + func testV2AddsKeyColumnsWithoutChangingTheFrozenBaseline() throws { + let baseline = Schema(versionedSchema: DashSchemaV1.self) + let live = Schema(versionedSchema: DashSchemaV2.self) + let oldKey = try XCTUnwrap(baseline.entities.first { $0.name == "PersistentPublicKey" }) + let newKey = try XCTUnwrap(live.entities.first { $0.name == "PersistentPublicKey" }) + for column in ["totalBudget", "expiresAt", "contractBoundsKind"] { + XCTAssertNil(oldKey.attributesByName[column]) + XCTAssertNotNil(newKey.attributesByName[column]) } } - - /// 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), - Schema(versionedSchema: DashSchemaV5.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". - 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..35dfdbc0097 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -0,0 +1,120 @@ +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 { + 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) + 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 { + 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) + 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 key = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(key.keyId, 3) + XCTAssertEqual(key.totalBudgetCredits, 100_000) + XCTAssertEqual(key.expiresAtMillis, 1_800_000_000_000) + XCTAssertEqual(key.identity?.identityId, Data(repeating: 0x35, count: 32)) + 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..acb6f1e12c6 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashSchemaFixtureSupport.swift @@ -0,0 +1,146 @@ +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) + let key = PersistentPublicKey( + keyId: 3, purpose: .authentication, securityLevel: .high, + keyType: .ecdsaSecp256k1, publicKeyData: Data(repeating: 0x02, count: 33), + totalBudget: 100_000, expiresAt: 1_800_000_000_000, + identityId: identity.identityIdString) + key.identity = identity + context.insert(key) + 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() + let storedKey = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + XCTAssertEqual(storedKey.totalBudgetCredits, 100_000) + XCTAssertEqual(storedKey.expiresAtMillis, 1_800_000_000_000) + XCTAssertEqual(storedKey.identity?.identityId, identity.identityId) + } +} 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/DataContractParserOncePerIdentityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift index 62f75619c78..b3bc5e014fc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DataContractParserOncePerIdentityTests.swift @@ -25,9 +25,9 @@ import SwiftData /// protocol constant. /// /// Unlike the perpetual and pre-programmed kinds this one has no column on -/// `PersistentToken`: `DashSchemaV5` is frozen, and a new stored property -/// would move the model's entity hash (see `DashModelContainer.modelTypes` -/// and `DashModelMigrationTests`). `PersistentToken.oncePerIdentityDistribution` +/// `PersistentToken`: the contract JSON already persists it, so another +/// column would duplicate the value and change the model's entity hash. +/// `PersistentToken.oncePerIdentityDistribution` /// therefore derives the value from the contract JSON stored on the owning /// `PersistentDataContract`, which is why these tests seed /// `serializedContract` rather than leaving it empty like the sibling parser @@ -407,6 +407,32 @@ final class DataContractParserOncePerIdentityTests: XCTestCase { ) } + /// Frozen and live model copies pass the same stored values. Updating + /// the payload timestamp must invalidate the memo even at the same byte count. + func testCacheAcceptsStoredValuesAndInvalidatesUpdatedPayload() throws { + let cache = TokenOncePerIdentityDistributionCache() + let firstPayload = try JSONSerialization.data(withJSONObject: [ + "tokens": ["0": tokenDict(oncePerIdentity: ["amount": 100])] + ]) + let nextPayload = try JSONSerialization.data(withJSONObject: [ + "tokens": ["0": tokenDict(oncePerIdentity: ["amount": 200])] + ]) + XCTAssertEqual(firstPayload.count, nextPayload.count) + let firstUpdate = Date(timeIntervalSince1970: 1_000) + let nextUpdate = firstUpdate.addingTimeInterval(1) + + for _ in 0..<2 { + XCTAssertEqual(cache.distribution( + contractId: contractId, serializedContract: firstPayload, + lastUpdated: firstUpdate, position: 0)?.amount, "100") + } + XCTAssertEqual(cache.decodeCount, 1) + XCTAssertEqual(cache.distribution( + contractId: contractId, serializedContract: nextPayload, + lastUpdated: nextUpdate, position: 0)?.amount, "200") + XCTAssertEqual(cache.decodeCount, 2) + } + /// Two contracts are two payloads: the memo is keyed per contract, not /// shared across them. func testSeparateContractsDecodeSeparately() throws { 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 6ce7443421e..00000000000 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v2.store and /dev/null differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store deleted file mode 100644 index 20d03939d0e..00000000000 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v3.store and /dev/null differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store deleted file mode 100644 index 05766e4bce8..00000000000 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v5.store and /dev/null differ 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/dash-v4.store b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store similarity index 83% rename from packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store rename to packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store index d0a9ea7aad8..944474f2fc6 100644 Binary files a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/dash-v4.store and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/SchemaStores/legacy-fd8d8d13e5/fixture.store differ 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/schema-models.json b/packages/swift-sdk/schema-models.json new file mode 100644 index 00000000000..8eda24d7f8f --- /dev/null +++ b/packages/swift-sdk/schema-models.json @@ -0,0 +1,55 @@ +{ + "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", + "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift" + }, + "value_types": [ + { + "path": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/TokenTypes.swift", + "names": [ + "ChangeControlRules", + "AuthorizedActionTakers", + "TokenPerpetualDistribution", + "TokenPreProgrammedDistribution", + "DistributionEvent", + "TokenDistributionChangeRules", + "TokenTradeMode", + "TokenLocalization" + ] + } + ] +} diff --git a/packages/swift-sdk/schema-releases.json b/packages/swift-sdk/schema-releases.json new file mode 100644 index 00000000000..07a64203a2b --- /dev/null +++ b/packages/swift-sdk/schema-releases.json @@ -0,0 +1,5 @@ +{ + "format_version": 1, + "schemas": {}, + "releases": {} +} 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_appstore_release.py b/packages/swift-sdk/scripts/freeze_appstore_release.py new file mode 100644 index 00000000000..f3c329f7701 --- /dev/null +++ b/packages/swift-sdk/scripts/freeze_appstore_release.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""Prepare a reviewed SwiftData snapshot from the iOS release-data branch. + +The caller supplies a release ID and a metadata commit, never a Platform ref. +Only the iOS monitor has Apple credentials; its publication proof is trusted +only after proving it belongs to the fixed release-data branch. All Git writes +take place in a temporary clone. --dry-run generates and checks the patch but +does not commit, push, or write to the GitHub API. +""" + +import argparse +import base64 +import contextlib +import hashlib +import http.client +import json +import os +from pathlib import Path +import re +import sqlite3 +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request + + +IOS_REPO = "dashpay/dashwallet-ios" +PLATFORM_REPO = "dashpay/platform" +BASE_BRANCH = "v4.2-dev" +DATA_BRANCH = "schema-release-data" +SDK = "packages/swift-sdk" +REGISTRY = f"{SDK}/schema-releases.json" +GENERATOR = f"{SDK}/scripts/freeze_schema_models.py" +GENERATED_TEST = f"{SDK}/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaRegistry.generated.swift" +SHA = re.compile(r"[0-9a-f]{40}") +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=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: " + 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, *, allow_executable=False): + entry = git(directory, "ls-tree", commit, "--", path) + 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}") + + +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, TimeoutError, ConnectionError, http.client.HTTPException, + json.JSONDecodeError, UnicodeDecodeError) as error: + if method == "GET" and attempt < 3: + self.sleep(2 ** attempt) + continue + 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 = [] + page = 1 + while True: + query = urllib.parse.urlencode({"head": f"dashpay:{branch}", "base": BASE_BRANCH, + "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: + return results + page += 1 + + +def git_environment(token): + 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="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, *, 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 + + 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() or dry_run: + 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 +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") + 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) + # 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") + 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 + # 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) + 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}") + 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)} + 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 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") + 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") + 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") + 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()) + 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)) + # 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, dry_run=dry_run) + 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 d1b3d18bb35..493fa2d4e33 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -1,91 +1,46 @@ #!/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. --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. +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 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 +import contextlib import dataclasses +import hashlib +import json +import pathlib +import plistlib +import sqlite3 import os import re import subprocess @@ -107,10 +62,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", @@ -147,26 +100,6 @@ "PersistentMasternode", ] -# Every model registered by V4, in the order `DashModelContainer` lists -# them: the shared graph with the asset lock back in its own slot and the -# tracked-masternode registry V2 added appended at the end. -# -# V4 is frozen as a whole graph rather than as a row for the three models it -# widened (`PersistentTxo`, `PersistentPendingInput`, `PersistentWallet`). -# Those three carry relationships, and a frozen model that names a -# relationship target absent from its own schema binds that bare name to the -# live type, which is the partial-freeze failure this file's header warns -# about. The rows above get away with being partial only because the models -# they freeze (`PersistentAssetLock`, `PersistentTrackedMasternode`) are -# relationship-isolated. -_V4_ASSET_LOCK_SLOT = V1_GRAPH_MODELS.index("PersistentInvitation") -V4_GRAPH_MODELS = ( - V1_GRAPH_MODELS[:_V4_ASSET_LOCK_SLOT] - + ["PersistentAssetLock"] - + V1_GRAPH_MODELS[_V4_ASSET_LOCK_SLOT:] - + ["PersistentTrackedMasternode"] -) - @dataclasses.dataclass(frozen=True) class Freeze: @@ -180,11 +113,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", @@ -192,19 +124,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",)), - # V4 as it shipped: the whole graph at the last commit before V5 added - # the key usage-limit columns to `PersistentPublicKey`. - Freeze( - "DashSchemaV4", - "787cac09e7", - tuple(V4_GRAPH_MODELS), - TOKEN_TYPES_FILE, - tuple(TOKEN_VALUE_TYPES), - ), ] HEADER = "import Foundation\nimport SwiftData\n\n" @@ -217,13 +136,13 @@ 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" ) -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): @@ -369,7 +288,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. @@ -407,9 +326,470 @@ 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}")) + return validate_inventory(inventory) + + +def validate_inventory(inventory): + if inventory.get("format_version") != 1: + raise SystemExit("unsupported historical schema model inventory") + models = inventory["models"] + groups = inventory["value_types"] + 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]: + 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 + + +# 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: + 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) 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") + 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"(?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}]]) + 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): + """The production worker pushes only to a temporary local bare repository.""" + + save = PublicationTests.save + + def setUp(self): + PublicationTests.setUp(self) + self.platform = self.root / "platform" + self.platform.mkdir() + git(self.platform, "init", "-b", worker.BASE_BRANCH) + git(self.platform, "config", "user.name", "Test") + git(self.platform, "config", "user.email", "test@example.invalid") + git(self.platform, "config", "commit.gpgsign", "false") + 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, 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") +r = json.loads(p.read_text()) +v = m["schema"]["schema_version"] +if v in r["schemas"] and r["schemas"][v]["schema"] != m["schema"]: + sys.exit("Schema conflict") +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") + self.commit = self.save() + self.remote = self.root / "platform.git" + git(self.root, "clone", "--bare", str(self.platform), str(self.remote)) + self.api = mock.Mock() + self.api.pull_requests.return_value = [] + self.api.request.return_value = {"html_url": "https://github.com/dashpay/platform/pull/1"} + self.real_git = worker.git + + def redirected_git(self, directory, *args, **kwargs): + if args[:3] == ("remote", "set-url", "origin"): + args = (*args[:3], str(self.remote)) + return self.real_git(directory, *args, **kwargs) + + def prepare(self, dry_run=False): + with mock.patch.object(worker, "git", side_effect=self.redirected_git), \ + mock.patch.object(worker, "GitHub", return_value=self.api), \ + contextlib.redirect_stdout(io.StringIO()): + worker.prepare(self.platform, self.data, self.proof["release_id"], self.commit, "test-token", dry_run) + + def test_dry_run_does_not_publish_or_modify_checkout(self): + 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() + 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: + 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"): + self.prepare(dry_run=True) + else: + self.prepare(dry_run=True) + 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") + 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() + + def test_push_then_retry_reuses_draft_pr_and_commit(self): + self.prepare() + self.assertTrue(self.api.request.call_args.args[2]["draft"]) + branch = "codex/freeze-swift-schema-v2.0.0" + before = git(self.remote, "rev-parse", branch) + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", branch), before) + self.api.request.assert_not_called() + + def test_retry_recovers_push_succeeded_pr_creation_failed(self): + self.api.request.side_effect = worker.ReleaseError("API unavailable") + with self.assertRaisesRegex(worker.ReleaseError, "API unavailable"): + self.prepare() + self.api.request.side_effect = None + self.api.request.reset_mock() + self.prepare() + self.api.request.assert_called_once() + + def test_human_edits_on_existing_branch_are_preserved(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + git(self.platform, "fetch", str(self.remote), branch) + git(self.platform, "checkout", "-b", "human", "FETCH_HEAD") + (self.platform / "human-review.txt").write_text("Keep this review change\n") + git(self.platform, "add", "human-review.txt") + git(self.platform, "commit", "-m", "human review") + 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"}] + 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) + 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 + 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"): + self.prepare() + self.api.request.assert_not_called() + + def test_already_merged_release_creates_no_commit_or_pr(self): + self.prepare() + 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)) + 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() + + 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" + before = json.loads(git(self.remote, "show", f"{branch}:{worker.REGISTRY}")) + self.manifest.update(app_version="2.1", build_number="22") + self.proof.update(release_id="release-22", app_version="2.1", build_number="22", + build_id="build-22", manifest_path="builds/org.dash.wallet/2.1/22/manifest.json") + self.commit = self.save() + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + self.prepare() + after = json.loads(git(self.remote, "show", f"{branch}:{worker.REGISTRY}")) + self.assertEqual(after["schemas"], before["schemas"]) + self.assertEqual(set(after["releases"]), {"release-21", "release-22"}) + self.api.request.assert_not_called() + + def test_schema_conflict_cannot_change_existing_branch(self): + self.prepare() + branch = "codex/freeze-swift-schema-v2.0.0" + before = git(self.remote, "rev-parse", branch) + self.manifest["schema"]["indexes"] = ["new index"] + self.commit = self.save() + self.api.pull_requests.return_value = [{"state": "open", "html_url": "https://example.invalid/pr"}] + self.api.request.reset_mock() + with self.assertRaisesRegex(worker.ReleaseError, "Schema conflict"): + self.prepare() + self.assertEqual(git(self.remote, "rev-parse", branch), before) + self.api.request.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 7820bb19e7c..d6ac0ed1d9b 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -13,6 +13,13 @@ """ import os +import contextlib +import hashlib +import json +import plistlib +import sqlite3 +from pathlib import Path +from unittest import mock import shutil import sys import tempfile @@ -39,9 +46,13 @@ def setUp(self): shutil.copytree( os.path.join(ROOT, gen.OUT_DIR), os.path.join(self.scratch, gen.OUT_DIR) ) + target = os.path.join(self.scratch, gen.TEST_REGISTRY_FILE) + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copyfile(os.path.join(ROOT, gen.TEST_REGISTRY_FILE), target) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(self.files), 73) + self.assertEqual(len(gen.render_baseline(ROOT)), 35) + self.assertIn(gen.TEST_REGISTRY_FILE, self.files) self.assertEqual(gen.check_problems(ROOT, self.files), []) def test_should_report_a_hand_edit_to_a_frozen_file(self): @@ -51,7 +62,7 @@ def test_should_report_a_hand_edit_to_a_frozen_file(self): self.assertEqual(gen.check_problems(self.scratch, self.files), [f"differs: {path}"]) def test_should_report_a_missing_and_a_stale_file(self): - missing = f"{gen.OUT_DIR}/DashSchemaV3+PersistentAssetLock.swift" + missing = f"{gen.OUT_DIR}/DashSchemaV1+PersistentAssetLock.swift" stale = f"{gen.OUT_DIR}/DashSchemaV9+PersistentGhost.swift" os.rename( os.path.join(self.scratch, missing), os.path.join(self.scratch, stale) @@ -82,5 +93,264 @@ def test_should_refuse_a_block_comment_rather_than_misread_it(self): gen.block_end(lines, 0) +class StorageGraphTests(unittest.TestCase): + def setUp(self): + self.model_path = gen.MODELS_DIR + "/PersistentThing.swift" + self.values_path = "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Types/Values.swift" + self.inventory = {"format_version": 1, "models": {"PersistentThing": self.model_path}, "value_types": []} + self.sources = {self.model_path: "@Model\npublic final class PersistentThing {\n var payload: Payload?\n}\n"} + + def include_values(self, names, source): + self.inventory["value_types"] = [{"path": self.values_path, "names": names}] + self.sources[self.values_path] = source + + def check(self): + gen.validate_storage_graph(gen.validate_inventory(self.inventory), self.sources) + + def test_should_refuse_a_real_omitted_stored_codable_type(self): + self.sources[self.values_path] = "public struct Payload: Codable {\n var value: String\n}\n" + with self.assertRaisesRegex(SystemExit, "PersistentThing.payload: stored type Payload is absent"): + self.check() + self.include_values(["Payload"], self.sources[self.values_path]) + self.check() + + def test_should_follow_transitive_struct_fields_and_enum_payloads_through_containers(self): + source = ("public struct Payload: Codable {\n var values: [String: Array>]\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() + self.addCleanup(shutil.rmtree, self.root) + registry = Path(self.root, gen.REGISTRY_FILE) + registry.parent.mkdir(parents=True) + registry.write_text(json.dumps({"format_version": 1, "schemas": {}, "releases": {"apple": {"build_id": "existing"}}})) + self.fixture = Path(self.root, "capture.store") + self.schema = { + "schema_version": "2.0.0", "model_checksum": "checksum", + "entity_hashes": {"PersistentThing": "abcd"}, "indexes": []} + 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),)) + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + self.manifest = { + "format_version": 1, "platform_sha": "a" * 40, "schema": self.schema, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + self.inventory = { + "format_version": 1, + "models": {"PersistentThing": gen.MODELS_DIR + "/PersistentThing.swift"}, + "value_types": []} + self.git_calls = [] + + def git(root, *args): + self.git_calls.append(args) + if args[0] == "show" and args[1].split(":", 1)[1] == gen.INVENTORY_FILE: + return json.dumps(self.inventory) + 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}") + + self.addCleanup(mock.patch.stopall) + mock.patch.object(gen, "git", side_effect=git).start() + + def test_should_copy_exact_captured_commit_and_preserve_release_metadata(self): + registry = gen.add_release(self.root, self.manifest, self.fixture) + entry = registry["schemas"]["2.0.0"] + self.assertEqual(registry["releases"], {"apple": {"build_id": "existing"}}) + self.assertEqual(Path(self.root, entry["fixture_path"]).read_bytes(), self.fixture.read_bytes()) + rendered = gen.render_snapshot(self.root, "2.0.0", entry) + self.assertIn('var old: String = "released"', rendered[f"{gen.OUT_DIR}/DashSchemaSnapshotV2+PersistentThing.swift"]) + 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 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, + "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() + for field in ["model_checksum", "indexes"]: + self.fixture.write_bytes(original) + schema = dict(self.schema) + 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" + database.execute("UPDATE Z_METADATA SET Z_PLIST = ?", (plistlib.dumps(metadata),)) + schema[field] = "changed" + else: + database.execute("CREATE INDEX fixture_index ON Z_METADATA (Z_PLIST)") + schema[field] = ["Z_METADATA fixture_index: CREATE INDEX fixture_index ON Z_METADATA (Z_PLIST)"] + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + manifest = {**self.manifest, "schema": schema, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + with self.subTest(field=field), self.assertRaisesRegex(SystemExit, "immutable shape"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_duplicate_checksum_under_another_version(self): + gen.add_release(self.root, self.manifest, self.fixture) + 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),)) + digest = hashlib.sha256(self.fixture.read_bytes()).hexdigest() + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "3.0.0"}, + "fixture_sha256": digest, "fixture_path": f"stores/{digest}.store"} + with self.assertRaisesRegex(SystemExit, "reuse a published model checksum"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_mismatched_evidence_before_writing_registry(self): + self.fixture.write_bytes(b"modified") + with self.assertRaisesRegex(SystemExit, "fixture does not match"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_reject_correct_digest_with_fabricated_schema_description(self): + manifest = {**self.manifest, "schema": {**self.schema, "entity_hashes": {"PersistentThing": "beef"}}} + with self.assertRaisesRegex(SystemExit, "does not match SQLite fixture metadata"): + gen.add_release(self.root, manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_reject_an_incorrect_store_version(self): + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "3.0.0"}} + with self.assertRaisesRegex(SystemExit, "schema version does not match"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_reject_inventory_that_omits_a_captured_entity(self): + self.inventory["models"] = {} + with self.assertRaisesRegex(SystemExit, "model membership"): + gen.add_release(self.root, self.manifest, self.fixture) + self.assertEqual(gen.read_registry(self.root)["schemas"], {}) + + def test_should_never_accept_a_new_v1_snapshot(self): + manifest = {**self.manifest, "schema": {**self.schema, "schema_version": "1.0.0"}} + with self.assertRaisesRegex(SystemExit, "V1 must remain unchanged"): + gen.add_release(self.root, manifest, self.fixture) + + def test_should_check_generated_registry_and_immutable_fixture(self): + registry = gen.add_release(self.root, self.manifest, self.fixture) + with mock.patch.object(gen, "render_baseline", return_value={}): + files = gen.render_all(self.root) + self.assertIn("DashSchemaSnapshotV2.self", files[gen.TEST_REGISTRY_FILE]) + Path(self.root, registry["schemas"]["2.0.0"]["fixture_path"]).write_bytes(b"edited") + with self.assertRaisesRegex(SystemExit, "immutable release fixture"): + gen.render_all(self.root) + + if __name__ == "__main__": unittest.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()