diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 2e801bda..459ef788 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -25,8 +25,7 @@ jobs: - name: Restore Projects run: | - dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net8.0 - dotnet restore PowerSync/PowerSync.Maui/PowerSync.Maui.csproj -p:TargetFrameworks=net8.0 + dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net10.0 - name: Generate API Metadata working-directory: docs diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2a4db78b..162608c9 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,8 +31,7 @@ jobs: - name: Restore Projects run: | - dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net8.0 - dotnet restore PowerSync/PowerSync.Maui/PowerSync.Maui.csproj -p:TargetFrameworks=net8.0 + dotnet restore PowerSync/PowerSync.Common/PowerSync.Common.csproj -p:TargetFrameworks=net10.0 - name: Generate API Metadata working-directory: docs diff --git a/.github/workflows/dev-packages.yml b/.github/workflows/dev-packages.yml index e63e2fe6..dedef039 100644 --- a/.github/workflows/dev-packages.yml +++ b/.github/workflows/dev-packages.yml @@ -24,7 +24,7 @@ jobs: with: dotnet-version: "10.0.x" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - name: Download PowerSync extension @@ -33,9 +33,6 @@ jobs: - name: Restore dependencies run: dotnet restore - - name: Install Android SDK for net8.0-android - run: dotnet build PowerSync/PowerSync.Common -t:InstallAndroidDependencies -f net8.0-android -p:AndroidSdkDirectory="$ANDROID_HOME" -p:AcceptAndroidSdkLicenses=True - - name: Extract Common Package Version from CHANGELOG.md shell: bash run: | @@ -50,20 +47,6 @@ jobs: - name: Run Pack For Common run: dotnet pack PowerSync/PowerSync.Common -c Release -o ${{ github.workspace }}/output - - name: Extract MAUI Package Version from CHANGELOG.md - shell: bash - run: | - MAUI_VERSION=$(awk '/^## [0-9]+\.[0-9]+\.[0-9]+-dev(\.[0-9]+)?$/ {print $2; exit}' PowerSync/PowerSync.Maui/CHANGELOG.md) - if [[ -z "$MAUI_VERSION" ]]; then - echo "Error: Invalid dev version found in PowerSync.Maui/CHANGELOG.md. Expected format: x.x.x-dev.x" - exit 1 - fi - echo "Detected Version: $MAUI_VERSION" - echo "VERSION=$MAUI_VERSION" >> $GITHUB_ENV - - - name: Run Pack For MAUI - run: dotnet pack PowerSync/PowerSync.Maui -c Release -o ${{ github.workspace }}/output - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -90,7 +73,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install Sign CLI tool run: dotnet tool install --tool-path . --prerelease sign @@ -142,7 +125,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Validate artifact signatures run: dotnet nuget verify ${{ github.workspace }}/SignedArtifacts/**/*.nupkg @@ -158,9 +141,3 @@ jobs: dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Common*.nupkg" --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://api.nuget.org/v3/index.json - - - name: Run Push For Maui - run: > - dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Maui*.nupkg" - --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" - --source https://api.nuget.org/v3/index.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71c0874a..dfd311e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,6 @@ jobs: outputs: common_version: ${{ steps.extract_version.outputs.common_version }} - maui_version: ${{ steps.extract_maui_version.outputs.maui_version }} steps: - name: Checkout Repository @@ -28,7 +27,7 @@ jobs: with: dotnet-version: "10.0.x" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - name: Download PowerSync extension @@ -37,9 +36,6 @@ jobs: - name: Restore dependencies run: dotnet restore - - name: Install Android SDK for net8.0-android - run: dotnet build PowerSync/PowerSync.Common -t:InstallAndroidDependencies -f net8.0-android -p:AndroidSdkDirectory="$ANDROID_HOME" -p:AcceptAndroidSdkLicenses=True - - name: Extract Common Package Version from CHANGELOG.md id: extract_version shell: bash @@ -56,22 +52,6 @@ jobs: - name: Run Pack For Common run: dotnet pack PowerSync/PowerSync.Common -c Release -o ${{ github.workspace }}/output - - name: Extract MAUI Package Version from CHANGELOG.md - id: extract_maui_version - shell: bash - run: | - MAUI_VERSION=$(awk '/^## [0-9]+\.[0-9]+\.[0-9]+/ {print $2; exit}' PowerSync/PowerSync.Maui/CHANGELOG.md) - if [[ -z "$MAUI_VERSION" ]]; then - echo "Error: Invalid version found in PowerSync.Maui/CHANGELOG.md. Expected format: '## x.x.x'" - exit 1 - fi - echo "Detected Version: $MAUI_VERSION" - echo "VERSION=$MAUI_VERSION" >> $GITHUB_ENV - echo "maui_version=$MAUI_VERSION" >> $GITHUB_OUTPUT - - - name: Run Pack For MAUI - run: dotnet pack PowerSync/PowerSync.Maui -c Release -o ${{ github.workspace }}/output - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: @@ -98,7 +78,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Install Sign CLI tool run: dotnet tool install --tool-path . --prerelease sign @@ -150,7 +130,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.0.x" + dotnet-version: "10.0.x" - name: Validate artifact signatures run: dotnet nuget verify ${{ github.workspace }}/SignedArtifacts/**/*.nupkg @@ -181,24 +161,3 @@ jobs: --target main \ --generate-notes \ SignedArtifacts/PowerSync.Common*.nupkg - - - name: Run Push For Maui - run: > - dotnet nuget push "${{ github.workspace }}\SignedArtifacts\PowerSync.Maui*.nupkg" - --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" - --source https://api.nuget.org/v3/index.json - - - name: Create GitHub Release For Maui - continue-on-error: true - shell: bash - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - MAUI_VERSION: ${{ needs.build.outputs.maui_version }} - run: | - gh release create "PowerSync.Maui@${MAUI_VERSION}" \ - --draft \ - --title "PowerSync.Maui@${MAUI_VERSION}" \ - --target main \ - --generate-notes \ - SignedArtifacts/PowerSync.Maui*.nupkg diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 110b65f1..a2920b91 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: jobs: build: strategy: - matrix: + matrix: runner: [windows-latest, windows-11-arm] name: Test Packages ${{ matrix.runner }} runs-on: ${{ matrix.runner }} @@ -18,16 +18,16 @@ jobs: - name: Setup .NET SDK uses: actions/setup-dotnet@v5 with: - dotnet-version: '10.0' + dotnet-version: "10.0" - - name: Install MAUI Workloads + - name: Install native workloads run: dotnet workload restore - + - name: Download PowerSync extension - run: dotnet run --project Tools/Setup + run: dotnet run --project Tools/Setup - name: Restore dependencies run: dotnet restore - + - name: Run tests run: dotnet test -v n --framework net10.0 diff --git a/.gitignore b/.gitignore index 688d8e68..4f214e65 100644 --- a/.gitignore +++ b/.gitignore @@ -53,18 +53,14 @@ TestResults/ *.VSIXProjectLaunch .project .vsconfig +.env *.db *.db-shm *.db-wal -#Core binaries - add rules after ps extension downloading is supported -*.dylib -*.dll -*.so -*.xcframework -.env -*NativeLibs +# PowerSync runtimes installed by Tools/Setup +**/runtimes/ # Ignore user id file user_id.txt diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index 04428fc4..204674da 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -4,7 +4,8 @@ - Full release. - `PowerSyncDatabase.OnChange` now returns the underlying raw table name instead of the view name to mirror PowerSync JS. -- Use `long` for op IDs instead of `string`. This affects the types returned by some methods used in `PowerSyncBackendConnector.UploadData`, namely `PowerSyncDatabase.GetNextCrudTransaction()` and `PowerSyncDatabase.GetCrudBatch()`. +- Use `long` for op IDs instead of `string`. This affects the types returned by some methods used in `PowerSyncBackendConnector.UploadData`, namely `PowerSyncDatabase.GetNextCrudTransaction()` and `PowerSyncDatabase.GetCrudBatch()`, as well as `ICustomCheckpointRequestConnector.PostCheckpointRequest`. +- Bundle and load native libraries on Android, iOS, and MacCatalyst. This allows `PowerSync.Common` to fully replace `PowerSync.Maui`. ## 1.0.0 (unlisted) diff --git a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs index 2466bc14..56cd4131 100644 --- a/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs +++ b/PowerSync/PowerSync.Common/Client/PowerSyncDatabase.cs @@ -154,6 +154,10 @@ public class PowerSyncDatabase : IPowerSyncDatabase protected IBucketStorageAdapter BucketStorageAdapter; + private readonly object currentStatusLock = new(); + /// + /// The latest published sync status. + /// public SyncStatus CurrentStatus { get; protected set; } protected CancellationTokenSource masterCts = new(); @@ -163,6 +167,7 @@ public class PowerSyncDatabase : IPowerSyncDatabase public ILogger Logger { get; protected set; } private readonly AsyncLock runExclusive = new(); + private readonly Func remoteFactory; public StreamingSyncImplementation? SyncStreamImplementation => ConnectionManager.SyncStreamImplementation; @@ -225,6 +230,8 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) await WaitForReady(); using (await runExclusive.LockAsync()) { + var syncStreamStatusCts = CancellationTokenSource.CreateLinkedTokenSource(masterCts.Token); + syncStreamImplementation = new StreamingSyncImplementation(new StreamingSyncImplementationOptions { Adapter = BucketStorageAdapter, @@ -245,20 +252,22 @@ public PowerSyncDatabase(PowerSyncDatabaseOptions options) Subscriptions = options.Subscriptions, CrudUploadThrottleMs = options.CrudUploadThrottleMs, TimeProvider = timeProvider, - Logger = Logger - }); - - var syncStreamStatusCts = CancellationTokenSource.CreateLinkedTokenSource(masterCts.Token); - var syncStreamStatusListener = syncStreamImplementation.Events.OnStatusChanged.ListenAsync(syncStreamStatusCts.Token); - var _ = Task.Run(async () => - { - await foreach (var update in syncStreamStatusListener) + Logger = Logger, + OnStatusChanged = status => { - CurrentStatus = new SyncStatus(new SyncStatusOptions(update.Status.Options) + if (syncStreamStatusCts.IsCancellationRequested) { - HasSynced = CurrentStatus?.HasSynced == true || update.Status.LastSyncedAt != null, - }); - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + return; + } + + lock (currentStatusLock) + { + CurrentStatus = new SyncStatus(new SyncStatusOptions(status.Options) + { + HasSynced = CurrentStatus?.HasSynced == true || status.LastSyncedAt != null, + }); + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } }); @@ -431,12 +440,16 @@ protected async Task ResolveOfflineSyncStatus() var parsed = JsonConvert.DeserializeObject(result.r); var parsedSyncStatus = CoreInstructionHelpers.CoreStatusToSyncStatus(parsed!); - var updatedStatus = CurrentStatus.CreateUpdatedStatus(parsedSyncStatus); - if (!updatedStatus.IsEqual(CurrentStatus)) + lock (currentStatusLock) { - CurrentStatus = updatedStatus; - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + var updatedStatus = CurrentStatus.CreateUpdatedStatus(parsedSyncStatus); + + if (!updatedStatus.IsEqual(CurrentStatus)) + { + CurrentStatus = updatedStatus; + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } } @@ -531,8 +544,11 @@ await Database.WriteTransaction(async tx => }); // The data has been deleted - reset the sync status - CurrentStatus = new SyncStatus(new SyncStatusOptions()); - Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + lock (currentStatusLock) + { + CurrentStatus = new SyncStatus(new SyncStatusOptions()); + Events.Emit(new PowerSyncDBEvents.StatusChangedEvent(CurrentStatus)); + } } /// diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs index 23a158e6..353c9c2b 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/BucketStorageAdapter.cs @@ -8,6 +8,7 @@ namespace PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; using PowerSync.Common.Utils; +using PowerSync.Common.Utils.Converters; public static class PowerSyncControlCommand { @@ -34,13 +35,15 @@ public static class PowerSyncControlConnectionState public class Checkpoint { [JsonProperty("last_op_id")] - public string LastOpId { get; set; } = null!; + [JsonConverter(typeof(StringLongConverter))] + public long LastOpId { get; set; } [JsonProperty("buckets")] public BucketChecksum[] Buckets { get; set; } = []; [JsonProperty("write_checkpoint")] - public string? WriteCheckpoint { get; set; } = null; + [JsonConverter(typeof(StringLongConverter))] + public long? WriteCheckpoint { get; set; } = null; [JsonProperty("streams")] public object[]? Streams { get; set; } = []; diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs index f83f4a64..c89ff2ae 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/OplogEntry.cs @@ -2,6 +2,8 @@ namespace PowerSync.Common.Client.Sync.Bucket; using Newtonsoft.Json; +using PowerSync.Common.Utils.Converters; + public class OplogEntryJSON { [JsonProperty("checksum")] @@ -17,7 +19,8 @@ public class OplogEntryJSON public string? ObjectType { get; set; } [JsonProperty("op_id")] - public string OpId { get; set; } = null!; + [JsonConverter(typeof(StringLongConverter))] + public long OpId { get; set; } [JsonProperty("op")] public string Op { get; set; } = null!; @@ -27,7 +30,7 @@ public class OplogEntryJSON } public class OplogEntry( - string opId, + long opId, OpType op, long checksum, string subkey, @@ -36,7 +39,7 @@ public class OplogEntry( object? data = null ) { - public string OpId { get; private set; } = opId; + public long OpId { get; private set; } = opId; public OpType Op { get; private set; } = op; public long Checksum { get; private set; } = checksum; public string Subkey { get; private set; } = subkey; diff --git a/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs b/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs index 21dbd193..75e92cd7 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs @@ -33,9 +33,10 @@ public SqliteBucketStorage(IDBAdapter db, ILogger? logger = null) updateCts = new CancellationTokenSource(); + var updates = db.Events.OnTablesUpdated.Listen(updateCts.Token); updateTask = Task.Run(() => { - foreach (var update in db.Events.OnTablesUpdated.Listen(updateCts.Token)) + foreach (var update in updates) { var tables = DBAdapterUtils.ExtractTableUpdates(update.TablesUpdated); if (tables.Contains(PSInternalTable.CRUD)) diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs index efc5adf7..c17c49c2 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/CoreInstructions.cs @@ -135,6 +135,7 @@ public class CoreSyncStatus public List Streams { get; set; } = []; [JsonProperty("internal_last_applied_checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long? LastAppliedCheckpointRequestId { get; set; } } diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index 3234950c..443d2e7d 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -13,6 +13,7 @@ namespace PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; using PowerSync.Common.Utils; +using PowerSync.Common.Utils.Converters; public class AdditionalConnectionOptions(int? retryDelayMs = null, int? crudUploadThrottleMs = null) { @@ -61,6 +62,13 @@ public class StreamingSyncImplementationOptions : AdditionalConnectionOptions public ILogger? Logger { get; init; } + /// + /// Called synchronously whenever the sync status changes, before the change is published on + /// . It runs while the status lock is held, so it + /// must not block or call back into the sync implementation. + /// + internal Action? OnStatusChanged { get; init; } + /// /// Source of the delays in the sync loops. Tests substitute a fake clock so they don't have to /// wait out real retry delays. @@ -193,8 +201,10 @@ public class StreamingSyncImplementation : ICloseable /// /// The highest checkpoint request id the core extension has reported as applied, if any. + /// Updated in the download loop and read in the repost loop. /// - internal long? LastAppliedCheckpointRequestId; + internal long? _lastAppliedCheckpointRequestId; + private readonly object _lastAppliedCheckpointLock = new(); private readonly ILogger logger; private SubscribedStream[] activeStreams; @@ -215,7 +225,7 @@ public class StreamingSyncImplementation : ICloseable public StreamingSyncImplementation(StreamingSyncImplementationOptions options) { Options = options; - SyncStatus = new SyncStatus(new SyncStatusOptions + _syncStatus = new SyncStatus(new SyncStatusOptions { Connected = false, Connecting = false, @@ -234,6 +244,23 @@ public StreamingSyncImplementation(StreamingSyncImplementationOptions options) CancellationTokenSource = null; } + private SyncStatus _syncStatus; + private readonly object _syncStatusLock = new(); + + /// + /// The current synchronization status. + /// + public SyncStatus SyncStatus + { + get + { + lock (_syncStatusLock) + { + return _syncStatus; + } + } + } + /// /// Indicates if the sync service is connected. /// @@ -244,11 +271,6 @@ public StreamingSyncImplementation(StreamingSyncImplementationOptions options) /// public DateTime? LastSyncedAt => SyncStatus.LastSyncedAt; - /// - /// The current synchronization status. - /// - public SyncStatus SyncStatus { get; protected set; } - public async Task Connect(PowerSyncConnectionOptions? options = null) { if (CancellationTokenSource != null) @@ -260,25 +282,41 @@ public async Task Connect(PowerSyncConnectionOptions? options = null) var tcs = new TaskCompletionSource(); var cts = new CancellationTokenSource(); - // Subscribe to events before starting StreamingSync to not miss the Connected == true event + // Subscribe to events before starting StreamingSync to not miss the first status change var listener = Events.OnStatusChanged.ListenAsync(cts.Token); streamingSyncTask = StreamingSync(CancellationTokenSource.Token, options); + // Resolves once the connection attempt has settled, which is when `Connecting` has been seen + // as true and became false again. A download error settles the attempt too: the download loop + // keeps retrying after one, and connecting shouldn't block for the whole retry loop. var _ = Task.Run(async () => { + var sawStartOfConnection = false; + await foreach (var status in listener) { - if (status.Status.Connected == true) + if (status.Status.Connecting) { - tcs.TrySetResult(true); - cts.Cancel(); - return; + sawStartOfConnection = true; + } + + if (status.Status.DataFlowStatus.DownloadError != null) + { + logger.LogWarning("Initial connect attempt did not successfully connect to server"); + } + else if (!sawStartOfConnection || status.Status.Connecting) + { + // Still connecting. + continue; } + + tcs.TrySetResult(true); + cts.Cancel(); + return; } // Connection closed prematurely - logger.LogWarning("Initial connect attempt did not successfully connect to server"); tcs.TrySetResult(true); }); @@ -423,9 +461,10 @@ protected async Task DownloadLoop(CancellationToken signal, PowerSyncConnectionO var retryDelayMs = options.RetryDelayMs ?? DEFAULT_RETRY_DELAY_MS; crudUpdateCts = new CancellationTokenSource(); + var crudUpdates = Options.Adapter.Events.OnCrudUpdate.ListenAsync(crudUpdateCts.Token); crudUpdateTask = Task.Run(async () => { - await foreach (var _ in Options.Adapter.Events.OnCrudUpdate.ListenAsync(crudUpdateCts.Token)) + await foreach (var _ in crudUpdates) { TriggerCrudUpload(); } @@ -655,7 +694,10 @@ protected async Task RepostUnacknowledgedCheckpointRequests(CancellationToken si /// internal bool IsCheckpointRequestApplied(long requestId) { - return LastAppliedCheckpointRequestId is not null && LastAppliedCheckpointRequestId >= requestId; + lock (_lastAppliedCheckpointLock) + { + return _lastAppliedCheckpointRequestId is not null && _lastAppliedCheckpointRequestId >= requestId; + } } protected record StreamingSyncIterationResult @@ -838,7 +880,10 @@ async Task HandleInstruction(NonInterruptingInstruction instruction) } break; case UpdateSyncStatus syncStatus: - LastAppliedCheckpointRequestId = syncStatus.Status.LastAppliedCheckpointRequestId; + lock (_lastAppliedCheckpointLock) + { + _lastAppliedCheckpointRequestId = syncStatus.Status.LastAppliedCheckpointRequestId; + } UpdateSyncStatus(CoreInstructionHelpers.CoreStatusToSyncStatusOptions(syncStatus.Status)); break; case FetchCredentials fetchCredentials: @@ -1197,40 +1242,50 @@ protected void UpdateSyncStatus(SyncStatusOptions options, UpdateSyncStatusOptio { try { - var updatedStatus = new SyncStatus(new SyncStatusOptions + lock (_syncStatusLock) { - Connected = options.Connected ?? SyncStatus.Connected, - Connecting = !options.Connected.GetValueOrDefault() && (options.Connecting ?? SyncStatus.Connecting), - LastSyncedAt = options.LastSyncedAt ?? SyncStatus.LastSyncedAt, - PriorityStatusEntries = options.PriorityStatusEntries ?? SyncStatus.PriorityStatusEntries, - DataFlow = new SyncDataFlowStatus + var updatedStatus = new SyncStatus(new SyncStatusOptions { - Uploading = options.DataFlow?.Uploading ?? SyncStatus.DataFlowStatus.Uploading, - Downloading = options.DataFlow?.Downloading ?? SyncStatus.DataFlowStatus.Downloading, - DownloadProgress = options.DataFlow?.DownloadProgress ?? SyncStatus.DataFlowStatus.DownloadProgress, - DownloadError = updateOptions?.ClearDownloadError == true ? null : options.DataFlow?.DownloadError ?? SyncStatus.DataFlowStatus.DownloadError, - UploadError = updateOptions?.ClearUploadError == true ? null : options.DataFlow?.UploadError ?? SyncStatus.DataFlowStatus.UploadError, - InternalStreamSubscriptions = options.DataFlow?.InternalStreamSubscriptions ?? SyncStatus.DataFlowStatus.InternalStreamSubscriptions - } - }); + Connected = options.Connected ?? _syncStatus.Connected, + Connecting = !options.Connected.GetValueOrDefault() && (options.Connecting ?? _syncStatus.Connecting), + LastSyncedAt = options.LastSyncedAt ?? _syncStatus.LastSyncedAt, + PriorityStatusEntries = options.PriorityStatusEntries ?? _syncStatus.PriorityStatusEntries, + DataFlow = new SyncDataFlowStatus + { + Uploading = options.DataFlow?.Uploading ?? _syncStatus.DataFlowStatus.Uploading, + Downloading = options.DataFlow?.Downloading ?? _syncStatus.DataFlowStatus.Downloading, + DownloadProgress = options.DataFlow?.DownloadProgress ?? _syncStatus.DataFlowStatus.DownloadProgress, + DownloadError = updateOptions?.ClearDownloadError == true ? null : options.DataFlow?.DownloadError ?? _syncStatus.DataFlowStatus.DownloadError, + UploadError = updateOptions?.ClearUploadError == true ? null : options.DataFlow?.UploadError ?? _syncStatus.DataFlowStatus.UploadError, + InternalStreamSubscriptions = options.DataFlow?.InternalStreamSubscriptions ?? _syncStatus.DataFlowStatus.InternalStreamSubscriptions + } + }); - if (!SyncStatus.Equals(updatedStatus)) - { - SyncStatus = updatedStatus; - logger.LogDebug("[Sync status changed]: {message}", updatedStatus.ToJSON()); + // TODO: Always false because Equals is reference equality. + // A SyncStatus.IsEqual method exists, but is very inefficient. + if (!_syncStatus.Equals(updatedStatus)) + { + _syncStatus = updatedStatus; - // Emit events using new SyncStatus objects to prevent local modifications propagating to StreamingSyncImplementation + // Suppress CA1873 (expensive arguments to logging function) + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("[Sync status changed]: {message}", updatedStatus.ToJSON()); - // Only trigger this if there was a change - Events.Emit(new StreamingSyncImplementationEvents.StatusChangedEvent(new SyncStatus(updatedStatus.Options))); + // Emit events using new _syncStatus objects to prevent local modifications propagating to StreamingSyncImplementation - // Emit StatusUpdated event wrapping a new SyncStatus object (prevents race conditions) - Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(new SyncStatus(updatedStatus.Options))); - } - else - { - // Emit StatusUpdated event directly wrapping `updatedStatus` (not exposed elsewhere) - Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(updatedStatus)); + Options.OnStatusChanged?.Invoke(new SyncStatus(updatedStatus.Options)); + + // Only trigger this if there was a change + Events.Emit(new StreamingSyncImplementationEvents.StatusChangedEvent(new SyncStatus(updatedStatus.Options))); + + // Emit StatusUpdated event wrapping a new SyncStatus object (prevents race conditions) + Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(new SyncStatus(updatedStatus.Options))); + } + else + { + // Emit StatusUpdated event directly wrapping `updatedStatus` (not exposed elsewhere) + Events.Emit(new StreamingSyncImplementationEvents.StatusUpdatedEvent(updatedStatus)); + } } } catch (Exception ex) @@ -1305,7 +1360,7 @@ private static double WithJitter(int delayMs) } internal record LegacyWriteCheckpointResponseData( - [property: JsonProperty("write_checkpoint")] long WriteCheckpoint + [property: JsonProperty("write_checkpoint"), JsonConverter(typeof(StringLongConverter))] long WriteCheckpoint ); internal record LegacyWriteCheckpointApiResponse( [property: JsonProperty("data")] LegacyWriteCheckpointResponseData Data diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs index 71694c77..11c501cc 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncTypes.cs @@ -1,5 +1,6 @@ using PowerSync.Common.Client.Sync.Bucket; using PowerSync.Common.DB.Crud; +using PowerSync.Common.Utils.Converters; using Newtonsoft.Json; @@ -128,6 +129,7 @@ public class StreamingSyncCheckpointDiff : StreamingSyncLine public class CheckpointDiff { [JsonProperty("last_op_id")] + [JsonConverter(typeof(StringLongConverter))] public long LastOpId { get; set; } [JsonProperty("updated_buckets")] @@ -137,6 +139,7 @@ public class CheckpointDiff public List RemovedBuckets { get; set; } = []; [JsonProperty("write_checkpoint")] + [JsonConverter(typeof(StringLongConverter))] public long WriteCheckpoint { get; set; } } @@ -197,6 +200,7 @@ public class CheckpointRequestPayload public string ClientId { get; set; } = ""; [JsonProperty("checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long CheckpointRequestId { get; set; } } @@ -209,5 +213,6 @@ public class CheckpointRequestResponse public class CheckpointRequestResponseData { [JsonProperty("checkpoint_request_id")] + [JsonConverter(typeof(StringLongConverter))] public long CheckpointRequestId { get; set; } } diff --git a/PowerSync/PowerSync.Common/Client/WatchManager.cs b/PowerSync/PowerSync.Common/Client/WatchManager.cs index 4df2210b..cbe1f4da 100644 --- a/PowerSync/PowerSync.Common/Client/WatchManager.cs +++ b/PowerSync/PowerSync.Common/Client/WatchManager.cs @@ -32,9 +32,12 @@ public WatchManager(PowerSyncDatabase db, CancellationToken masterToken) this.db = db; this.masterToken = masterToken; + var tableUpdates = db.Database.Events.OnTablesUpdated.ListenAsync(masterToken); + var schemaChanges = db.Events.OnSchemaChanged.ListenAsync(masterToken); + // Dispatch two tasks, one for TableUpdated events and one for SchemaChanged events - _ = Task.Run(RunTableUpdateLoop); - _ = Task.Run(RunSchemaChangeLoop); + _ = Task.Run(() => RunTableUpdateLoop(tableUpdates)); + _ = Task.Run(() => RunSchemaChangeLoop(schemaChanges)); } public IAsyncEnumerable Watch(string sql, object?[]? parameters, SQLWatchOptions? options) @@ -138,11 +141,11 @@ private async IAsyncEnumerable Stream(WatchSubscription subscription, Func } } - private async Task RunTableUpdateLoop() + private async Task RunTableUpdateLoop(IAsyncEnumerable updates) { try { - await foreach (var update in db.Database.Events.OnTablesUpdated.ListenAsync(masterToken)) + await foreach (var update in updates) { // Prevent a single bad notification from taking down the entire TablesUpdated loop try @@ -176,11 +179,11 @@ private async Task RunTableUpdateLoop() } } - private async Task RunSchemaChangeLoop() + private async Task RunSchemaChangeLoop(IAsyncEnumerable schemaChanges) { try { - await foreach (var _ in db.Events.OnSchemaChanged.ListenAsync(masterToken)) + await foreach (var _ in schemaChanges) { foreach (var entry in watches) { @@ -196,18 +199,7 @@ private async Task RunSchemaChangeLoop() } private static HashSet ExpandTableNames(IEnumerable tables) => - [.. tables.SelectMany(table => new[] { $"{PS_DATA_PREFIX}{table}", $"{PS_DATA_LOCAL_PREFIX}{table}" })]; - - private static string InternalToFriendlyTableName(string internalName) - { - if (internalName.StartsWith(PS_DATA_PREFIX)) - return internalName.Substring(PS_DATA_PREFIX.Length); - - if (internalName.StartsWith(PS_DATA_LOCAL_PREFIX)) - return internalName.Substring(PS_DATA_LOCAL_PREFIX.Length); - - return internalName; - } + [.. tables.SelectMany(table => new[] { $"{PS_DATA_PREFIX}{table}", $"{PS_DATA_LOCAL_PREFIX}{table}", $"{table}" })]; } /// diff --git a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs index cdb4ec9e..6055c929 100644 --- a/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs +++ b/PowerSync/PowerSync.Common/MDSQLite/MDSQLiteAdapter.cs @@ -12,6 +12,10 @@ namespace PowerSync.Common.MDSQLite; using PowerSync.Common.DB; using PowerSync.Common.Utils; +#if IOS || MACCATALYST +using Foundation; +#endif + public class MDSQLiteAdapterOptions() { public string Name { get; set; } = null!; @@ -111,9 +115,10 @@ private async Task Init() // Register TablesUpdated listener tablesUpdatedCts = new CancellationTokenSource(); + var notifications = writeConnection.ListenAsync(tablesUpdatedCts.Token); tablesUpdatedTask = Task.Run(async () => { - await foreach (var notification in writeConnection.ListenAsync(tablesUpdatedCts.Token)) + await foreach (var notification in notifications) { if (notification.TablesUpdated != null) { @@ -154,13 +159,30 @@ protected virtual void LoadExtensions(SqliteConnection db) /// /// Loads the bundled PowerSync core SQLite extension. Override on - /// platform-specific adapters (e.g. MAUI iOS/Android) where the native library - /// lives outside the desktop runtime path. + /// platform-specific adapters where the native library lives + /// outside the standard runtimes path. /// protected virtual void LoadDefaultPowerSyncExtension(SqliteConnection db) { - var path = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); - db.LoadExtension(path, "sqlite3_powersync_init"); + string extensionPath; + +#if IOS || MACCATALYST + var bundlePath = + NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath + ?? throw new Exception("Could not find PowerSync SQLite extension bundle path"); + extensionPath = Path.Combine(bundlePath, "powersync-sqlite-core"); +#elif ANDROID + extensionPath = "libpowersync"; +#else + extensionPath = PowerSyncDesktopPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); +#endif + + // Use manual command instead of db.LoadExtension(), since that has issues on iOS + using var loadExtension = db.CreateCommand(); + loadExtension.CommandText = "SELECT load_extension(@path, @entryPoint)"; + loadExtension.Parameters.AddWithValue("@path", extensionPath); + loadExtension.Parameters.AddWithValue("@entryPoint", "sqlite3_powersync_init"); + loadExtension.ExecuteNonQuery(); } public async Task Close() diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.csproj b/PowerSync/PowerSync.Common/PowerSync.Common.csproj index 01794909..6fb77ba4 100644 --- a/PowerSync/PowerSync.Common/PowerSync.Common.csproj +++ b/PowerSync/PowerSync.Common/PowerSync.Common.csproj @@ -39,26 +39,39 @@ + - - + - - - - - PreserveNewest - + + + - - - - + + + + + + + + + + + + + + + - + + diff --git a/PowerSync/PowerSync.Common/PowerSync.Common.targets b/PowerSync/PowerSync.Common/PowerSync.Common.targets deleted file mode 100644 index 0c5bcba2..00000000 --- a/PowerSync/PowerSync.Common/PowerSync.Common.targets +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs b/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs index 61c7cbdf..73f092ed 100644 --- a/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs +++ b/PowerSync/PowerSync.Common/Utils/Converters/StringLongConverter.cs @@ -6,10 +6,6 @@ namespace PowerSync.Common.Utils.Converters; /// Converts a long to and from a string when converting JSON values. Used /// for converting checkpoint request IDs from a long to a string before being /// passed to the core extension. -/// -/// TODO: This is not currently in use because checkpoint request IDs are -/// currently represented as strings, however this is going to change -/// in the 1.0 release. /// internal class StringLongConverter : JsonConverter { @@ -42,6 +38,6 @@ public override object ReadJson(JsonReader reader, Type objectType, object? exis return result; } - throw new JsonSerializationException($"Cannot convert value {val} to long."); + throw new JsonSerializationException($"Cannot convert value '{val}' to long."); } } diff --git a/PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs b/PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs similarity index 97% rename from PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs rename to PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs index 8aa7c3a4..f6c07f77 100644 --- a/PowerSync/PowerSync.Common/Utils/PowerSyncPathResolver.cs +++ b/PowerSync/PowerSync.Common/Utils/PowerSyncDesktopPathResolver.cs @@ -2,7 +2,7 @@ namespace PowerSync.Common.Utils; using System.Runtime.InteropServices; -public static class PowerSyncPathResolver +public static class PowerSyncDesktopPathResolver { public static string GetNativeLibraryPath(string packagePath) { diff --git a/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets b/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets new file mode 100644 index 00000000..4f1aa9d5 --- /dev/null +++ b/PowerSync/PowerSync.Common/buildTransitive/PowerSync.Common.targets @@ -0,0 +1,21 @@ + + + + + <_PowerSyncUnusableRuntimeTarget Include="@(RuntimeTargetsCopyLocalItems)" + Condition="'%(NuGetPackageId)' == 'PowerSync.Common' And !$([System.Text.RegularExpressions.Regex]::IsMatch('%(RuntimeIdentifier)', '^(win|linux|osx)'))" /> + + + + diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index 72f1d515..5cb0969c 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -2,8 +2,7 @@ ## 1.0.1 -- Full release. -- Upstream PowerSync.Common version bump (See Powersync.Common changelog 1.0.1 for more information) +- ⚠️ Deprecate `PowerSync.Maui`. Use `PowerSync.Common` and `MDSQLiteAdapter` / `MDSQLiteDBOpenFactory` instead. ## 1.0.0 (unlisted) diff --git a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj index f8864efb..552547ce 100644 --- a/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj +++ b/PowerSync/PowerSync.Maui/PowerSync.Maui.csproj @@ -1,5 +1,4 @@  - net9.0;net10.0;net9.0-android;net10.0-android $(TargetFrameworks);net9.0-ios;net10.0-ios;net9.0-maccatalyst;net10.0-maccatalyst @@ -19,7 +18,6 @@ https://github.com/powersync-ja/powersync-dotnet/PowerSync/PowerSync.Maui/CHANGELOG.md powersync local-first local-storage state-management offline sql db persistence sqlite sync icon.png - NU5100;1591 README.md true true @@ -27,44 +25,11 @@ - + - - Designer - - - - - - - - - Framework - False - - - - - - - Framework - False - - - - - - - - - - diff --git a/PowerSync/PowerSync.Maui/README.md b/PowerSync/PowerSync.Maui/README.md index bc75882c..af6137a4 100644 --- a/PowerSync/PowerSync.Maui/README.md +++ b/PowerSync/PowerSync.Maui/README.md @@ -1,45 +1,3 @@ # PowerSync SDK .NET MAUI -This package provides .NET Multi-platform App UI (MAUI) integration for PowerSync, designed to work with the PowerSync.Common package for cross-platform mobile and desktop applications. - -## ⚠️ Project Status & Release Note - -This package is in beta and is considered ready for production use for tested use cases. See our feature status definitions [here](https://docs.powersync.com/resources/feature-status). - -## Installation - -This package is published on [NuGet](https://www.nuget.org/packages/PowerSync.Maui) and requires PowerSync.Common to also be installed. - -```bash -dotnet add package PowerSync.Maui -dotnet add package PowerSync.Common -``` - -## Usage - -Initialization differs slightly from our Common SDK when using MAUI. - -```csharp - -private record ListResult(string id, string name, string owner_id, string created_at); - -static async Task Main() { - - // Ensures the DB file is stored in a platform appropriate location - var dbPath = Path.Combine(FileSystem.AppDataDirectory, "maui-example.db"); - var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() - { - DbFilename = dbPath - }); - - var Db = new PowerSyncDatabase(new PowerSyncDatabaseOptions() - { - Database = factory, // Supply a factory - Schema = AppSchema.PowerSyncSchema, - }); - - await db.Init(); - - var lists = await db.GetAll("select * from lists"); -} -``` +- ⚠️ This package is deprecated. Use `PowerSync.Common` and `MDSQLiteAdapter` / `MDSQLiteDBOpenFactory` instead. diff --git a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs deleted file mode 100644 index 0d6e3993..00000000 --- a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteAdapter.cs +++ /dev/null @@ -1,55 +0,0 @@ -namespace PowerSync.Maui.SQLite; - -using Microsoft.Data.Sqlite; - -using PowerSync.Common.MDSQLite; - -// iOS/MacCatalyst specific imports -#if IOS || MACCATALYST -using Foundation; -#endif - -public class MAUISQLiteAdapter : MDSQLiteAdapter -{ - public MAUISQLiteAdapter(MDSQLiteAdapterOptions options) : base(options) - { - } - - // The bundled PowerSync extension lives in a platform-specific location on - // iOS/MacCatalyst/Android — the desktop runtime path used by the base class - // does not resolve to it. Override only the PowerSync-extension load hook; - // user-supplied custom extensions still flow through MDSQLiteAdapter.LoadExtensions - // unchanged, so consumers can freely combine the bundled extension (via the - // LoadPowerSyncExtension flag) with their own. - protected override void LoadDefaultPowerSyncExtension(SqliteConnection db) - { -#if IOS || MACCATALYST - LoadExtensionApple(db); -#elif ANDROID - db.LoadExtension("libpowersync"); -#else - base.LoadDefaultPowerSyncExtension(db); -#endif - } - - private static void LoadExtensionApple(SqliteConnection db) - { -#if IOS || MACCATALYST - var bundlePath = Foundation.NSBundle.FromIdentifier("co.powersync.sqlitecore")?.BundlePath; - if (bundlePath == null) - { - throw new Exception("Could not find PowerSync SQLite extension bundle path"); - } - - var filePath = - Path.Combine(bundlePath, "powersync-sqlite-core"); - - using var loadExtension = db.CreateCommand(); - loadExtension.CommandText = "SELECT load_extension(@path, @entryPoint)"; - loadExtension.Parameters.AddWithValue("@path", filePath); - loadExtension.Parameters.AddWithValue("@entryPoint", "sqlite3_powersync_init"); - loadExtension.ExecuteNonQuery(); -#endif - } -} - diff --git a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs b/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs deleted file mode 100644 index c8d35088..00000000 --- a/PowerSync/PowerSync.Maui/SQLite/MAUISQLiteDBOpenFactory.cs +++ /dev/null @@ -1,26 +0,0 @@ -using PowerSync.Common.DB; - -namespace PowerSync.Maui.SQLite; - -using PowerSync.Common.Client; -using PowerSync.Common.MDSQLite; - - -public class MAUISQLiteDBOpenFactory : ISQLOpenFactory -{ - private readonly MDSQLiteOpenFactoryOptions options; - - public MAUISQLiteDBOpenFactory(MDSQLiteOpenFactoryOptions options) - { - this.options = options; - } - - public IDBAdapter OpenDatabase() - { - return new MAUISQLiteAdapter(new MDSQLiteAdapterOptions - { - Name = options.DbFilename, - SqliteOptions = options.SqliteOptions - }); - } -} diff --git a/PowerSync/PowerSync.Maui/build/ApiDefinition.cs b/PowerSync/PowerSync.Maui/build/ApiDefinition.cs deleted file mode 100644 index 5fe8ef43..00000000 --- a/PowerSync/PowerSync.Maui/build/ApiDefinition.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace PowerSync.Maui.build -{ - // Empty API definition - allows xcframework to be included without managed bindings -} diff --git a/README.md b/README.md index a48185b4..af113abf 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,6 @@ Packages are published to [NuGet](https://www.nuget.org/profiles/PowerSync). - [PowerSync.Common](./PowerSync/PowerSync.Common/README.md) - Core package: .NET implementation of a PowerSync database connector and streaming sync bucket implementation. Packages meant for specific platforms will extend functionality of `Common`. -- [PowerSync.Maui](./PowerSync/PowerSync.Maui/README.md) - - Extends the PowerSync.Common package to provide the .NET Multi-platform App UI (MAUI) integration for PowerSync for cross-platform mobile and desktop applications. ## Demo Apps / Example Projects @@ -31,41 +29,9 @@ Demo applications are located in the [`demos/`](./demos/) directory. Also see ou This PowerSync SDK supports the following target frameworks: -- **.NET 9** - [Latest version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) -- **.NET 8** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) -- **.NET 6** - supported for compatibility with older projects -- **.NET Standard 2.0** - for compatibility with older libraries and frameworks, tested/verified older versions will be listed below. - -- .NET Framework 4.8: - - To get a .NET Framework 4.8 working with this SDK add the following to your `.csproj` file: - - ```xml - - ... - - win-x86;win-x64 - win-x64 - - - - ... - - - - ``` - - and create a `IsExternalInit.cs` file in your project with the following contents: - - ```cs - using System.ComponentModel; - - namespace System.Runtime.CompilerServices - { - [EditorBrowsable(EditorBrowsableState.Never)] - internal class IsExternalInit { } - } - ``` +- **.NET 10** - [Current LTS Version, used for development of this project](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- **.NET 9** - [Current STS Version](https://dotnet.microsoft.com/en-us/download/dotnet/9.0) +- **.NET 8** - [Previous LTS Version, supported until November 10, 2026](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) --- diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs index 840cbfd2..8909886d 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/PowerSyncDatabaseTests.cs @@ -302,7 +302,7 @@ await db.Execute( Assert.All(ids, n => Assert.Equal(id, n)); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task ReadWhileWriteIsRunning() { var sem = new TaskCompletionSource(); @@ -357,7 +357,7 @@ public async Task BatchExecute() Assert.Equal(make2, result[1].make); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task QueueSimultaneousExecutions() { var order = new List(); @@ -376,7 +376,7 @@ await db.WriteLock(async context => Assert.Equal(expectedOrder, order); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task CallUpdateHookOnChanges() { var result = new TaskCompletionSource(); @@ -401,7 +401,7 @@ public async Task CallUpdateHookOnChanges() await result.Task; } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task ReflectWriteTransactionUpdatesOnReadConnections() { var watched = new TaskCompletionSource(); @@ -584,7 +584,7 @@ await db.Execute( Assert.Equal(make, dynamicAsset?.make); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task Watch_Cancelled() { int callCount = 0; @@ -616,7 +616,7 @@ public async Task Watch_Cancelled() Assert.Equal(2, callCount); } - [Fact(Timeout = 3000)] + [Fact(Timeout = 5000)] public async Task Watch_MultipleCancelled() { int callCount = 0; @@ -667,7 +667,7 @@ void RunQuery(CancellationTokenSource cts, SemaphoreSlim sem) Assert.Equal(3, callCount); } - [Fact(Timeout = 3000)] + [Fact(Timeout = 5000)] public async Task Watch_SchemaReset() { var dbId = Guid.NewGuid().ToString(); @@ -842,7 +842,7 @@ public async Task Watch_TriggerImmediately_False() Assert.Equal(3, current[0].count); } - [Fact(Timeout = 2000)] + [Fact(Timeout = 5000)] public async Task Watch_CancelsOnTokenCancellation() { var tcs = new TaskCompletionSource(); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs index 01227bfb..50f149c7 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/CheckpointRequestsTests.cs @@ -68,9 +68,9 @@ public async Task CheckpointRequests_RequestsCheckpointsForUpdates() { Checkpoint = new() { - LastOpId = "1", + LastOpId = 1, Buckets = [MockDataFactory.Bucket("a", 1, subscriptions: Array.Empty())], - WriteCheckpoint = _syncService.LastWriteCheckpoint.ToString(), + WriteCheckpoint = _syncService.LastWriteCheckpoint, } }); _syncService.PushLine(new StreamingSyncDataJSON @@ -82,7 +82,7 @@ public async Task CheckpointRequests_RequestsCheckpointsForUpdates() new OplogEntryJSON { Checksum = 0, - OpId = "1", + OpId = 1, ObjectId = "id", ObjectType = "lists", Op = "REMOVE", @@ -143,9 +143,9 @@ public async Task CheckpointRequests_RepostsCurrentCheckpointUntilApplied() { Checkpoint = new() { - LastOpId = "0", + LastOpId = 0, Buckets = [], - WriteCheckpoint = _syncService.LastWriteCheckpoint.ToString(), + WriteCheckpoint = _syncService.LastWriteCheckpoint, } }); _syncService.PushLine(new StreamingSyncCheckpointComplete { CheckpointComplete = new() { LastOpId = "0" } }); @@ -217,10 +217,7 @@ public async Task CheckpointRequests_DownloadIsRetriedOnCheckpointRequest() var iterationsBefore = _syncService.Requests.Count; // Destroy the connection by sending a bogus line. - _syncService.PushLine(new StreamingSyncCheckpoint - { - Checkpoint = new() { LastOpId = "invalid line", Buckets = [] } - }); + _syncService.PushLine("""{"checkpoint":{"last_op_id":"invalid line","buckets":[]}}"""); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.DownloadError != null); var start = DateTime.UtcNow; @@ -295,7 +292,7 @@ public async Task CheckpointRequests_ReadsSyncLinesBeforeCheckpointRequestsAreRe _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new() { LastOpId = "0", Buckets = [], WriteCheckpoint = "1" } + Checkpoint = new() { LastOpId = 0, Buckets = [], WriteCheckpoint = 1 } }); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.Downloading); @@ -326,7 +323,7 @@ public async Task CheckpointRequests_WaitsUntilDataIsApplied() var checkpoint = await _db.RequestCheckpoint(); _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); Assert.False(checkpoint.HasSynced); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); @@ -367,7 +364,7 @@ public async Task CheckpointRequests_ThrowsOnDisconnectButCanConnectAgain() await _db.Connect(new TestConnector(), new() { CheckpointMode = new CheckpointMode.Requests() }); _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); await checkpoint.WaitForSync(); @@ -515,10 +512,7 @@ public async Task CheckpointRequests_RequestThrowsIfCanceledWhileWaitingForSeed( }; // Destroy the connection with a bogus line: checkpoint requests are no longer ready. - _syncService.PushLine(new StreamingSyncCheckpoint - { - Checkpoint = new() { LastOpId = "invalid line", Buckets = [] } - }); + _syncService.PushLine("""{"checkpoint":{"last_op_id":"invalid line","buckets":[]}}"""); await TestUtils.WaitForAsync(() => _db.CurrentStatus.DataFlowStatus.DownloadError != null); using var cts = new CancellationTokenSource(); @@ -557,7 +551,7 @@ public async Task CheckpointRequests_WaitForSyncThrowsIfCanceledWhileWaiting() // Abandoning a wait doesn't invalidate the request, it can still be awaited again. _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); @@ -589,7 +583,7 @@ public async Task CheckpointRequests_WaitForSyncCancellationIsPerCaller() _syncService.PushLine(new StreamingSyncCheckpoint { - Checkpoint = new Checkpoint { LastOpId = "0", WriteCheckpoint = "2", }, + Checkpoint = new Checkpoint { LastOpId = 0, WriteCheckpoint = 2, }, }); _syncService.PushLine(MockDataFactory.CheckpointComplete("0")); diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs index 09c5effa..f40ad126 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/SyncTests.cs @@ -41,6 +41,21 @@ public async Task DisposeAsync() """{ "checkpoint_complete":{ "last_op_id":"2"} }""" ]; + /// + /// The status published on the database must be current by the time Connect returns, so that a + /// caller reading CurrentStatus - or subscribing to OnStatusChanged - right after connecting + /// doesn't observe a status from before the connection settled. + /// + [Fact(Timeout = 15000)] + public async Task ConnectPublishesStatusBeforeReturning() + { + await db.Init(); + await db.Connect(new TestConnector()); + + Assert.True(db.CurrentStatus.Connected); + Assert.False(db.CurrentStatus.Connecting); + } + [Fact] public async Task SyncCreateOperationTest() { diff --git a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs index 41843828..507a173c 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/MDSQLite/MDSQLiteAdapterTests.cs @@ -53,7 +53,7 @@ public async Task DisablingCoreExtensionPreventsPowerSyncFromLoading() public async Task LoadsCustomPowerSyncExtensionFromOverriddenPath() { var dbName = $"MDSQLiteAdapter-{Guid.NewGuid():N}.db"; - var sourcePath = PowerSyncPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); + var sourcePath = PowerSyncDesktopPathResolver.GetNativeLibraryPath(AppContext.BaseDirectory); var customPath = Path.Combine( Path.GetTempPath(), $"powersync-ext-copy-{Guid.NewGuid():N}{Path.GetExtension(sourcePath)}" diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs index 54c9b80e..8d897fc7 100644 --- a/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs +++ b/Tests/PowerSync/PowerSync.Common.Tests/Utils/Sync/MockSyncService.cs @@ -120,10 +120,11 @@ public static async Task NextStatus(PowerSyncDatabase db) { var tcs = new TaskCompletionSource(); var cts = new CancellationTokenSource(); + var listener = db.Events.OnStatusChanged.ListenAsync(cts.Token); _ = Task.Run(async () => { - await foreach (var update in db.Events.OnStatusChanged.ListenAsync(cts.Token)) + await foreach (var update in listener) { tcs.TrySetResult(update.Status); cts?.Cancel(); @@ -142,7 +143,7 @@ public static StreamingSyncCheckpoint Checkpoint(long lastOpId, List -/// Execute with `dotnet run --project Tools/Setup` -/// -public class PowerSyncSetup -{ - private const string VERSION = "0.5.3"; - - private const string GITHUB_BASE_URL = $"https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v{VERSION}"; - - private readonly HttpClient _httpClient; - private readonly string _basePath; - - public PowerSyncSetup() - { - _httpClient = new HttpClient(); - _basePath = Path.Combine(AppContext.BaseDirectory, "../../../../..", "PowerSync"); - } - - public async Task RunSetup() - { - try - { - await SetupDesktop(); - await SetupMauiIos(); - await SetupMauiAndroid(); - await SetupMauiMacCatalyst(); - } - finally - { - _httpClient?.Dispose(); - } - } - - public async Task SetupDesktop() - { - Console.WriteLine("Setting up Desktop libraries..."); - - var runtimeConfigs = GetDesktopRuntimeConfigs(); - var commonPath = Path.Combine(_basePath, "PowerSync.Common"); - - foreach (var config in runtimeConfigs) - { - await ProcessDesktopRuntime(commonPath, config); - } - } - - private static Dictionary GetDesktopRuntimeConfigs() - { - return new Dictionary - { - { "osx-x64", new RuntimeConfig("libpowersync_x64.macos.dylib", "libpowersync.dylib") }, - { "osx-arm64", new RuntimeConfig("libpowersync_aarch64.macos.dylib", "libpowersync.dylib") }, - { "linux-x64", new RuntimeConfig("libpowersync_x64.linux.so", "libpowersync.so") }, - { "linux-arm64", new RuntimeConfig("libpowersync_aarch64.linux.so", "libpowersync.so") }, - { "win-x64", new RuntimeConfig("powersync_x64.dll", "powersync.dll") }, - { "win-arm64", new RuntimeConfig("powersync_aarch64.dll", "powersync.dll") } - }; - } - - private async Task ProcessDesktopRuntime(string basePath, KeyValuePair runtimeConfig) - { - var (rid, config) = runtimeConfig; - var nativeDir = Path.Combine(basePath, "runtimes", rid, "native"); - - try - { - Directory.CreateDirectory(nativeDir); - - var downloadPath = Path.Combine(nativeDir, config.OriginalFileName); - var finalPath = Path.Combine(nativeDir, config.FinalFileName); - - var downloadUrl = $"{GITHUB_BASE_URL}/{config.OriginalFileName}"; - - await DownloadFile(downloadUrl, downloadPath); - File.Move(downloadPath, finalPath, overwrite: true); - - Console.WriteLine($"✓ {rid}: {config.OriginalFileName} → {config.FinalFileName}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to process {rid}: {ex.Message}"); - } - } - - public async Task SetupMauiIos() - { - Console.WriteLine("Setting up MAUI iOS libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "iOS", "NativeLibs"); - var config = new ArchiveConfig( - "powersync-sqlite-core.xcframework.zip", - "powersync-sqlite-core.xcframework" - ); - - await ProcessArchiveDownload(nativeDir, config, GITHUB_BASE_URL); - } - - public async Task SetupMauiAndroid() - { - Console.WriteLine("Setting up MAUI Android libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "Android", "jniLibs"); - - try - { - Directory.CreateDirectory(nativeDir); - - await Task.WhenAll( - DownloadAndroidLibrary("libpowersync_aarch64.android.so ", nativeDir, "arm64-v8a"), - DownloadAndroidLibrary("libpowersync_armv7.android.so ", nativeDir, "armeabi-v7a"), - DownloadAndroidLibrary("libpowersync_x86.android.so ", nativeDir, "x86"), - DownloadAndroidLibrary("libpowersync_x64.android.so ", nativeDir, "x86_64") - ); - - Console.WriteLine($"✓ Android: Downloaded native libraries"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to setup Android: {ex.Message}"); - } - } - - private async Task DownloadAndroidLibrary(string filename, string jniLibsDir, string arch) - { - var targetDir = Path.Combine(jniLibsDir, arch); - Directory.CreateDirectory(targetDir); - var targetFile = Path.Combine(targetDir, "libpowersync.so"); - await DownloadFile($"{GITHUB_BASE_URL}/{filename}", targetFile); - } - - public async Task SetupMauiMacCatalyst() - { - Console.WriteLine("Setting up MAUI MacCatalyst libraries..."); - - var nativeDir = Path.Combine(_basePath, "PowerSync.Maui", "Platforms", "MacCatalyst", "NativeLibs"); - var config = new ArchiveConfig( - "powersync-sqlite-core.xcframework.zip", - "powersync-sqlite-core.xcframework" - ); - - await ProcessArchiveDownload(nativeDir, config, GITHUB_BASE_URL); - } - - private async Task ProcessArchiveDownload(string nativeDir, ArchiveConfig config, string baseUrl) - { - try - { - Directory.CreateDirectory(nativeDir); - - var downloadPath = Path.Combine(nativeDir, config.ArchiveFileName); - var extractedPath = Path.Combine(nativeDir, config.ExtractedName); - var downloadUrl = $"{baseUrl}/{config.ArchiveFileName}"; - - await DownloadFile(downloadUrl, downloadPath); - - // Clean up existing extraction - if (Directory.Exists(extractedPath)) - Directory.Delete(extractedPath, recursive: true); - - ExtractZipPreservingSymlinks(downloadPath, nativeDir); - File.Delete(downloadPath); - - Console.WriteLine($"✓ Extracted {config.ArchiveFileName} → {config.ExtractedName}"); - } - catch (Exception ex) - { - Console.Error.WriteLine($"✗ Failed to process archive: {ex.Message}"); - } - } - - private static void ExtractZipPreservingSymlinks(string zipPath, string destDir) - { - // ZipFile.ExtractToDirectory does not preserve symlinks, which breaks - // macOS/Catalyst .xcframework bundles. Use `unzip` on Unix instead. - if (!OperatingSystem.IsWindows()) - { - var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo - { - FileName = "unzip", - ArgumentList = { "-o", zipPath, "-d", destDir }, - RedirectStandardOutput = true, - RedirectStandardError = true, - })!; - proc.WaitForExit(); - if (proc.ExitCode != 0) - throw new Exception($"unzip exited with code {proc.ExitCode}: {proc.StandardError.ReadToEnd()}"); - } - else - { - ZipFile.ExtractToDirectory(zipPath, destDir); - } - } - - private async Task DownloadFile(string url, string outputPath) - { - Console.WriteLine($"📥 Downloading: {Path.GetFileName(outputPath)}"); - - using var response = await _httpClient.GetAsync(url); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"Download failed: {response.StatusCode} {response.ReasonPhrase}"); - } - - await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); - await response.Content.CopyToAsync(fileStream); - } - - private static void CleanupPaths(params string[] paths) - { - foreach (var path in paths) - { - try - { - if (File.Exists(path)) - File.Delete(path); - else if (Directory.Exists(path)) - Directory.Delete(path, recursive: true); - } - catch (Exception ex) - { - Console.WriteLine($"⚠️ Cleanup warning for {path}: {ex.Message}"); - } - } - } - - private record RuntimeConfig(string OriginalFileName, string FinalFileName); - private record ArchiveConfig(string ArchiveFileName, string ExtractedName); -} - -public class Program -{ - static async Task Main(string[] args) - { - var setup = new PowerSyncSetup(); - await setup.RunSetup(); - } -} +using System.IO.Compression; +using System.Xml; +using System.Xml.Linq; + +namespace Tools.Setup; + +/// +/// Downloads the powersync-sqlite-core native libraries into PowerSync.Common/runtimes, +/// laid out using NuGet's RID conventions so a single package can serve every target: +/// +/// runtimes/{rid}/native/... desktop and Android, resolved by RID +/// runtimes/ios/native/*.xcframework.zip +/// runtimes/maccatalyst/native/*.xcframework.zip +/// +/// The Apple slices stay zipped on purpose. NuGet pack/extract and ordinary file copies +/// drop symlinks, which corrupts the versioned MacCatalyst framework bundle and makes +/// codesign fail with "bundle format is ambiguous". The Apple SDK unzips these itself +/// and embeds the framework into App.app/Frameworks. +/// +/// Execute with `dotnet run --project Tools/Setup` +/// +public class PowerSyncSetup +{ + private const string VERSION = "0.5.3"; + + private const string GITHUB_BASE_URL = $"https://github.com/powersync-ja/powersync-sqlite-core/releases/download/v{VERSION}"; + + private const string XCFRAMEWORK_NAME = "powersync-sqlite-core.xcframework"; + + private readonly HttpClient _httpClient; + private readonly string _commonPath; + + public PowerSyncSetup() + { + _httpClient = new HttpClient(); + _commonPath = Path.Combine(AppContext.BaseDirectory, "../../../../..", "PowerSync", "PowerSync.Common"); + } + + public async Task RunSetup() + { + try + { + await SetupRuntimes(); + await SetupApple(); + } + finally + { + _httpClient?.Dispose(); + } + } + + /// + /// Every target whose native library is a single downloadable file: one entry per RID. + /// + public async Task SetupRuntimes() + { + Console.WriteLine("Setting up native libraries..."); + + foreach (var config in GetRuntimeConfigs()) + { + await ProcessRuntime(config); + } + } + + private static Dictionary GetRuntimeConfigs() + { + return new Dictionary + { + // Desktop + { "osx-x64", new RuntimeConfig("libpowersync_x64.macos.dylib", "libpowersync.dylib") }, + { "osx-arm64", new RuntimeConfig("libpowersync_aarch64.macos.dylib", "libpowersync.dylib") }, + { "linux-x64", new RuntimeConfig("libpowersync_x64.linux.so", "libpowersync.so") }, + { "linux-arm64", new RuntimeConfig("libpowersync_aarch64.linux.so", "libpowersync.so") }, + { "win-x64", new RuntimeConfig("powersync_x64.dll", "powersync.dll") }, + { "win-arm64", new RuntimeConfig("powersync_aarch64.dll", "powersync.dll") }, + + // Android. These reach the APK's ABI directories via AndroidNativeLibrary items + // in PowerSync.Common.csproj (ProjectReference) and NuGet RID resolution (PackageReference). + { "android-arm64", new RuntimeConfig("libpowersync_aarch64.android.so", "libpowersync.so") }, + { "android-x64", new RuntimeConfig("libpowersync_x64.android.so", "libpowersync.so") }, + { "android-arm", new RuntimeConfig("libpowersync_armv7.android.so", "libpowersync.so") }, + { "android-x86", new RuntimeConfig("libpowersync_x86.android.so", "libpowersync.so") }, + }; + } + + private async Task ProcessRuntime(KeyValuePair runtimeConfig) + { + var (rid, config) = runtimeConfig; + var nativeDir = Path.Combine(_commonPath, "runtimes", rid, "native"); + + try + { + Directory.CreateDirectory(nativeDir); + + var downloadPath = Path.Combine(nativeDir, config.OriginalFileName); + var finalPath = Path.Combine(nativeDir, config.FinalFileName); + + var downloadUrl = $"{GITHUB_BASE_URL}/{config.OriginalFileName}"; + + await DownloadFile(downloadUrl, downloadPath); + File.Move(downloadPath, finalPath, overwrite: true); + + Console.WriteLine($"✓ {rid}: {config.OriginalFileName} → {config.FinalFileName}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"✗ Failed to process {rid}: {ex.Message}"); + throw; + } + } + + /// + /// Apple ships one xcframework covering every Apple platform, so it needs its own path: + /// download once, then write a per-platform copy carrying only the slices that platform + /// needs. The published archive is ~135MB — it includes tvOS, watchOS and macOS slices we + /// do not target, plus a dSYM per slice — which trims down to a few MB. + /// + public async Task SetupApple() + { + Console.WriteLine("Setting up Apple libraries..."); + + var tempZip = Path.Combine(Path.GetTempPath(), $"{XCFRAMEWORK_NAME}.zip"); + + try + { + await DownloadFile($"{GITHUB_BASE_URL}/{XCFRAMEWORK_NAME}.zip", tempZip); + + WriteTrimmedXcframework(tempZip, "ios", ["ios-arm64", "ios-arm64_x86_64-simulator"]); + WriteTrimmedXcframework(tempZip, "maccatalyst", ["ios-arm64_x86_64-maccatalyst"]); + } + catch (Exception ex) + { + Console.Error.WriteLine($"✗ Failed to process Apple libraries: {ex.Message}"); + throw; + } + finally + { + if (File.Exists(tempZip)) File.Delete(tempZip); + } + } + + private void WriteTrimmedXcframework(string sourceZip, string rid, string[] keepSlices) + { + var nativeDir = Path.Combine(_commonPath, "runtimes", rid, "native"); + Directory.CreateDirectory(nativeDir); + + var targetZip = Path.Combine(nativeDir, $"{XCFRAMEWORK_NAME}.zip"); + if (File.Exists(targetZip)) File.Delete(targetZip); + + using var source = ZipFile.OpenRead(sourceZip); + using var targetStream = new FileStream(targetZip, FileMode.Create, FileAccess.Write, FileShare.None); + using var target = new ZipArchive(targetStream, ZipArchiveMode.Create); + + var infoPlistPath = $"{XCFRAMEWORK_NAME}/Info.plist"; + + foreach (var entry in source.Entries) + { + // Debug symbols are the bulk of the download and are not needed to build against. + if (entry.FullName.Contains("/dSYMs/")) continue; + + var slice = GetSliceName(entry.FullName); + if (slice != null && !keepSlices.Contains(slice)) continue; + + // Copying the raw bytes plus ExternalAttributes is what preserves symlinks: they + // are stored as entries whose content is the link target and whose unix mode bits + // live in ExternalAttributes. Extracting and re-zipping would lose them. + var copy = target.CreateEntry(entry.FullName, CompressionLevel.Optimal); + copy.ExternalAttributes = entry.ExternalAttributes; + copy.LastWriteTime = entry.LastWriteTime; + + using var output = copy.Open(); + + if (entry.FullName == infoPlistPath) + { + using var plistStream = entry.Open(); + using var trimmed = TrimInfoPlist(plistStream, keepSlices); + trimmed.CopyTo(output); + } + else + { + using var input = entry.Open(); + input.CopyTo(output); + } + } + + Console.WriteLine($"✓ {rid}: {XCFRAMEWORK_NAME}.zip ({string.Join(", ", keepSlices)})"); + } + + /// + /// Returns the xcframework slice a zip entry belongs to, or null for entries that sit + /// outside a slice (the root Info.plist, LICENSE, README). + /// + private static string? GetSliceName(string entryPath) + { + var parts = entryPath.Split('/'); + if (parts.Length < 3 || parts[0] != XCFRAMEWORK_NAME) return null; + return parts[1]; + } + + /// + /// Drops the removed slices from the xcframework's AvailableLibraries list, so the Apple + /// SDK does not go looking for slices that are no longer present in the archive. + /// + private static MemoryStream TrimInfoPlist(Stream plistStream, string[] keepSlices) + { + // XmlResolver is null so the Apple DTD named by the DOCTYPE is never fetched, while + // DtdProcessing.Parse keeps the DOCTYPE itself in the output. + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = null }; + using var reader = XmlReader.Create(plistStream, settings); + var doc = XDocument.Load(reader); + + var librariesArray = doc.Root?.Element("dict") + ?.Elements("key") + .FirstOrDefault(k => k.Value == "AvailableLibraries") + ?.NextNode as XElement; + + librariesArray?.Elements("dict") + .Where(d => !keepSlices.Contains(PlistString(d, "LibraryIdentifier"))) + .Remove(); + + var output = new MemoryStream(); + doc.Save(output); + output.Position = 0; + return output; + } + + private static string? PlistString(XElement dict, string key) + { + var keyElement = dict.Elements("key").FirstOrDefault(k => k.Value == key); + return (keyElement?.NextNode as XElement)?.Value; + } + + private async Task DownloadFile(string url, string outputPath) + { + Console.WriteLine($"📥 Downloading: {Path.GetFileName(outputPath)}"); + + using var response = await _httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Download failed: {response.StatusCode} {response.ReasonPhrase}"); + } + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write, FileShare.None); + await response.Content.CopyToAsync(fileStream); + } + + private record RuntimeConfig(string OriginalFileName, string FinalFileName); +} + +public class Program +{ + static async Task Main() + { + var setup = new PowerSyncSetup(); + await setup.RunSetup(); + } +} + diff --git a/Tools/Setup/Setup.csproj b/Tools/Setup/Setup.csproj index c5c3bf8e..858fcbdd 100644 --- a/Tools/Setup/Setup.csproj +++ b/Tools/Setup/Setup.csproj @@ -1,7 +1,8 @@  - Exe net10.0 + enable + enable diff --git a/demos/MAUITodo/Data/PowerSyncData.cs b/demos/MAUITodo/Data/PowerSyncData.cs index d5e2d600..9176ee71 100644 --- a/demos/MAUITodo/Data/PowerSyncData.cs +++ b/demos/MAUITodo/Data/PowerSyncData.cs @@ -10,7 +10,6 @@ using PowerSync.Common.Client.Sync; using PowerSync.Common.Client.Sync.Stream; using PowerSync.Common.MDSQLite; -using PowerSync.Maui.SQLite; namespace MAUITodo.Data; @@ -31,7 +30,7 @@ public PowerSyncData() var logger = loggerFactory.CreateLogger("PowerSyncLogger"); var dbPath = Path.Combine(FileSystem.AppDataDirectory, "example.db"); - var factory = new MAUISQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() + var factory = new MDSQLiteDBOpenFactory(new MDSQLiteOpenFactoryOptions() { DbFilename = dbPath }); diff --git a/demos/MAUITodo/MAUITodo.csproj b/demos/MAUITodo/MAUITodo.csproj index 9f38150b..9c5cd30d 100644 --- a/demos/MAUITodo/MAUITodo.csproj +++ b/demos/MAUITodo/MAUITodo.csproj @@ -1,10 +1,9 @@  - com.companyname.todo net9.0-android;net10.0-android - $(TargetFrameworks);net9.0-windows;net10.0-windows + $(TargetFrameworks);net9.0-windows10.0.19041.0;net10.0-windows10.0.19041.0 $(TargetFrameworks);net9.0-ios;net9.0-maccatalyst;net10.0-ios;net10.0-maccatalyst Exe @@ -64,6 +63,5 @@ - diff --git a/docs/README.md b/docs/README.md index 74e25b9b..9c3cc73e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,4 +27,4 @@ dotnet docfx docfx.json --serve ## Notes -Projects are built with only the `net8.0` target present (see the `properties` field in `docfx.json`) so that the MAUI workloads and the native `powersync-sqlite-core` binaries are not required to build the docs. +Projects are built with only the `net10.0` target present (see the `properties` field in `docfx.json`) so that the MAUI workloads and the native `powersync-sqlite-core` binaries are not required to build the docs. diff --git a/docs/docfx.json b/docs/docfx.json index 4c7aee77..96d0d6d6 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -5,17 +5,13 @@ "src": [ { "src": "../PowerSync", - "files": [ - "PowerSync.Common/PowerSync.Common.csproj", - "PowerSync.Maui/PowerSync.Maui.csproj" - ] + "files": ["PowerSync.Common/PowerSync.Common.csproj"] } ], "dest": "api", - "filter": "filter.yml", "properties": { - "TargetFramework": "net8.0", - "TargetFrameworks": "net8.0" + "TargetFramework": "net10.0", + "TargetFrameworks": "net10.0" }, "namespaceLayout": "nested", "memberLayout": "separatePages", @@ -27,13 +23,7 @@ "content": [ { "files": ["**/*.{md,yml}"], - "exclude": [ - "_site/**", - "obj/**", - "template/**", - "README.md", - "filter.yml" - ] + "exclude": ["_site/**", "obj/**", "template/**", "README.md"] } ], "resource": [ diff --git a/docs/filter.yml b/docs/filter.yml deleted file mode 100644 index 257f884c..00000000 --- a/docs/filter.yml +++ /dev/null @@ -1,5 +0,0 @@ -apiRules: - # Ignore the `build/ApiDefinition.cs` file in PowerSync.Maui (required to build for Apple targets) - - exclude: - uidRegex: ^PowerSync\.Maui\.build$ - type: Namespace diff --git a/docs/toc.yml b/docs/toc.yml index 8c52d26d..5528c4ab 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,2 +1,2 @@ - name: API Reference - uid: PowerSync + uid: PowerSync.Common diff --git a/root.sln b/root.sln index 072d04eb..1519e87c 100644 --- a/root.sln +++ b/root.sln @@ -21,8 +21,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MAUITodo", "demos\MAUITodo\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WPF", "demos\WPF\WPF.csproj", "{AF297026-0BEA-4B8E-97C9-6540C6D52B36}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Maui", "PowerSync\PowerSync.Maui\PowerSync.Maui.csproj", "{A4A91B9F-0C86-41CB-BEF0-C002819C43BE}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.IntegrationTests", "Tests\PowerSync\PowerSync.Common.IntegrationTests\PowerSync.Common.IntegrationTests.csproj", "{EB81D453-777D-40B5-A504-4144906ADBF4}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerSync.Common.PerformanceTests", "Tests\PowerSync\PowerSync.Common.PerformanceTests\PowerSync.Common.PerformanceTests.csproj", "{B2BA37D6-0549-455D-9520-B3D21C7876D0}" @@ -97,18 +95,6 @@ Global {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x64.Build.0 = Release|Any CPU {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x86.ActiveCfg = Release|Any CPU {AF297026-0BEA-4B8E-97C9-6540C6D52B36}.Release|x86.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x64.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x64.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x86.ActiveCfg = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Debug|x86.Build.0 = Debug|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|Any CPU.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x64.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x64.Build.0 = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x86.ActiveCfg = Release|Any CPU - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE}.Release|x86.Build.0 = Release|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|Any CPU.Build.0 = Debug|Any CPU {EB81D453-777D-40B5-A504-4144906ADBF4}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -144,7 +130,6 @@ Global {D7FDA714-D29F-4D85-B3F2-74C6810A36F9} = {9144195A-C68F-4B1E-A574-474EDD424D6C} {B8B2A9E2-FEC9-495B-B03E-23078E7B651D} = {9144195A-C68F-4B1E-A574-474EDD424D6C} {AF297026-0BEA-4B8E-97C9-6540C6D52B36} = {9144195A-C68F-4B1E-A574-474EDD424D6C} - {A4A91B9F-0C86-41CB-BEF0-C002819C43BE} = {B1D87BA9-8812-4EFA-BBBE-1FF1EEEB5433} {EB81D453-777D-40B5-A504-4144906ADBF4} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} {B2BA37D6-0549-455D-9520-B3D21C7876D0} = {C784FBE4-CC1E-4A0A-AE8E-6B818DD3724D} EndGlobalSection