diff --git a/.changeset/sharp-singers-search.md b/.changeset/sharp-singers-search.md new file mode 100644 index 000000000..78cfa9cd9 --- /dev/null +++ b/.changeset/sharp-singers-search.md @@ -0,0 +1,8 @@ +--- +'@powersync/common': minor +'@powersync/web': minor +'@powersync/node': minor +'@powersync/react-native': minor +--- + +Report progress information about downloaded rows. Sync progress is available through `SyncStatus.downloadProgress`. diff --git a/demos/django-react-native-todolist/package.json b/demos/django-react-native-todolist/package.json index f086cbde5..9e2f80ddc 100644 --- a/demos/django-react-native-todolist/package.json +++ b/demos/django-react-native-todolist/package.json @@ -11,7 +11,7 @@ "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/metro-runtime": "^4.0.1", "@expo/vector-icons": "^14.0.0", - "@journeyapps/react-native-quick-sqlite": "^2.4.3", + "@journeyapps/react-native-quick-sqlite": "^2.4.4", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-group-chat/package.json b/demos/react-native-supabase-group-chat/package.json index 139547de1..ea0094020 100644 --- a/demos/react-native-supabase-group-chat/package.json +++ b/demos/react-native-supabase-group-chat/package.json @@ -22,7 +22,7 @@ "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/metro-runtime": "^4.0.1", "@faker-js/faker": "8.3.1", - "@journeyapps/react-native-quick-sqlite": "^2.4.3", + "@journeyapps/react-native-quick-sqlite": "^2.4.4", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-todolist/app/views/todos/lists.tsx b/demos/react-native-supabase-todolist/app/views/todos/lists.tsx index acb50a709..f793c1c76 100644 --- a/demos/react-native-supabase-todolist/app/views/todos/lists.tsx +++ b/demos/react-native-supabase-todolist/app/views/todos/lists.tsx @@ -7,8 +7,9 @@ import prompt from 'react-native-prompt-android'; import { router, Stack } from 'expo-router'; import { LIST_TABLE, TODO_TABLE, ListRecord } from '../../../library/powersync/AppSchema'; import { useSystem } from '../../../library/powersync/system'; -import { useQuery, useStatus } from '@powersync/react-native'; +import { useQuery } from '@powersync/react-native'; import { ListItemWidget } from '../../../library/widgets/ListItemWidget'; +import { GuardBySync } from '../../../library/widgets/GuardBySync'; const description = (total: number, completed: number = 0) => { return `${total - completed} pending, ${completed} completed`; @@ -16,7 +17,6 @@ const description = (total: number, completed: number = 0) => { const ListsViewWidget: React.FC = () => { const system = useSystem(); - const status = useStatus(); const { data: listRecords } = useQuery(` SELECT ${LIST_TABLE}.*, COUNT(${TODO_TABLE}.id) AS total_tasks, SUM(CASE WHEN ${TODO_TABLE}.completed = true THEN 1 ELSE 0 END) as completed_tasks @@ -78,26 +78,26 @@ const ListsViewWidget: React.FC = () => { ); }} /> - - {!status.hasSynced ? ( - Busy with sync... - ) : ( - listRecords.map((r) => ( - deleteList(r.id)} - onPress={() => { - router.push({ - pathname: 'views/todos/edit/[id]', - params: { id: r.id } - }); - }} - /> - )) - )} - + + + {( + listRecords.map((r) => ( + deleteList(r.id)} + onPress={() => { + router.push({ + pathname: 'views/todos/edit/[id]', + params: { id: r.id } + }); + }} + /> + )) + )} + + diff --git a/demos/react-native-supabase-todolist/library/widgets/GuardBySync.tsx b/demos/react-native-supabase-todolist/library/widgets/GuardBySync.tsx new file mode 100644 index 000000000..902c0a314 --- /dev/null +++ b/demos/react-native-supabase-todolist/library/widgets/GuardBySync.tsx @@ -0,0 +1,40 @@ +import { useStatus } from '@powersync/react'; +import { FC, ReactNode } from 'react'; +import { View } from 'react-native'; +import { Text, LinearProgress } from '@rneui/themed'; + +/** + * A component that renders its child if the database has been synced at least once and shows + * a progress indicator otherwise. + */ +export const GuardBySync: FC<{ children: ReactNode; priority?: number }> = ({ children, priority }) => { + const status = useStatus(); + + const hasSynced = priority == null ? status.hasSynced : status.statusForPriority(priority).hasSynced; + if (hasSynced) { + return children; + } + + // If we haven't completed a sync yet, show a progress indicator! + const allProgress = status.downloadProgress; + const progress = priority == null ? allProgress : allProgress?.untilPriority(priority); + + return ( + + {progress != null ? ( + <> + + {progress.downloadedOperations == progress.totalOperations ? ( + Applying server-side changes + ) : ( + + Downloaded {progress.downloadedOperations} out of {progress.totalOperations}. + + )} + + ) : ( + + )} + + ); +}; diff --git a/demos/react-native-supabase-todolist/package.json b/demos/react-native-supabase-todolist/package.json index 8e210783d..57f37052d 100644 --- a/demos/react-native-supabase-todolist/package.json +++ b/demos/react-native-supabase-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.3", - "@journeyapps/react-native-quick-sqlite": "^2.4.3", + "@journeyapps/react-native-quick-sqlite": "^2.4.4", "@powersync/attachments": "workspace:*", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", diff --git a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx index 1dc2e6217..1f1a686b7 100644 --- a/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx +++ b/demos/react-supabase-todolist/src/app/views/todo-lists/page.tsx @@ -18,6 +18,7 @@ import { LISTS_TABLE } from '@/library/powersync/AppSchema'; import { NavigationPage } from '@/components/navigation/NavigationPage'; import { SearchBarWidget } from '@/components/widgets/SearchBarWidget'; import { TodoListsWidget } from '@/components/widgets/TodoListsWidget'; +import { GuardBySync } from '@/components/widgets/GuardBySync'; export default function TodoListsPage() { const powerSync = usePowerSync(); @@ -53,7 +54,9 @@ export default function TodoListsPage() { - {!status.hasSynced ?

Busy with sync...

: } + + +
{/* TODO use a dialog service in future, this is just a simple example app */} = ({ children, priority }) => { + const status = useStatus(); + + const hasSynced = priority == null ? status.hasSynced : status.statusForPriority(priority).hasSynced; + if (hasSynced) { + return children; + } + + // If we haven't completed a sync yet, show a progress indicator! + const allProgress = status.downloadProgress; + const progress = priority == null ? allProgress : allProgress?.untilPriority(priority); + + return ( + + {progress != null ? ( + <> + + + {progress.downloadedOperations == progress.totalOperations ? ( + Applying server-side changes + ) : ( + + Downloaded {progress.downloadedOperations} out of {progress.totalOperations}. + + )} + + + ) : ( + + )} + + ); +}; diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 1d6d2a71b..c78912c99 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -32,6 +32,7 @@ import { type PowerSyncConnectionOptions, type RequiredAdditionalConnectionOptions } from './sync/stream/AbstractStreamingSyncImplementation.js'; +import { FULL_SYNC_PRIORITY } from '../db/crud/SyncProgress.js'; export interface DisconnectAndClearOptions { /** When set to false, data in local-only tables is preserved. */ @@ -146,11 +147,6 @@ export const isPowerSyncDatabaseOptionsWithSettings = (test: any): test is Power return typeof test == 'object' && isSQLOpenOptions(test.database); }; -/** - * The priority used by the core extension to indicate that a full sync was completed. - */ -const FULL_SYNC_PRIORITY = 2147483647; - export abstract class AbstractPowerSyncDatabase extends BaseObserver { /** * Transactions should be queued in the DBAdapter, but we also want to prevent diff --git a/packages/common/src/client/SQLOpenFactory.ts b/packages/common/src/client/SQLOpenFactory.ts index 05c90dc51..44d5b21e4 100644 --- a/packages/common/src/client/SQLOpenFactory.ts +++ b/packages/common/src/client/SQLOpenFactory.ts @@ -7,7 +7,7 @@ export interface SQLOpenOptions { dbFilename: string; /** * Directory where the database file is located. - * + * * When set, the directory must exist when the database is opened, it will * not be created automatically. */ diff --git a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts index df31253e2..c7bf525e7 100644 --- a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts +++ b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts @@ -30,6 +30,13 @@ export interface SyncLocalDatabaseResult { checkpointFailures?: string[]; } +export type SavedProgress = { + atLast: number; + sinceLast: number; +}; + +export type BucketOperationProgress = Record; + export interface BucketChecksum { bucket: string; priority?: number; @@ -65,6 +72,7 @@ export interface BucketStorageAdapter extends BaseObserver; + getBucketOperationProgress(): Promise; syncLocalDatabase( checkpoint: Checkpoint, diff --git a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts index 2dab15fd5..cd6686a77 100644 --- a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts +++ b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts @@ -5,6 +5,7 @@ import { BaseObserver } from '../../../utils/BaseObserver.js'; import { MAX_OP_ID } from '../../constants.js'; import { BucketChecksum, + BucketOperationProgress, BucketState, BucketStorageAdapter, BucketStorageListener, @@ -91,6 +92,13 @@ export class SqliteBucketStorage extends BaseObserver imp return result; } + async getBucketOperationProgress(): Promise { + const rows = await this.db.getAll<{ name: string; count_at_last: number; count_since_last: number }>( + 'SELECT name, count_at_last, count_since_last FROM ps_buckets' + ); + return Object.fromEntries(rows.map((r) => [r.name, { atLast: r.count_at_last, sinceLast: r.count_since_last }])); + } + async saveSyncData(batch: SyncDataBatch) { await this.writeTransaction(async (tx) => { let count = 0; @@ -199,7 +207,21 @@ export class SqliteBucketStorage extends BaseObserver imp 'sync_local', arg ]); - return result == 1; + if (result == 1) { + if (priority == null) { + const bucketToCount = Object.fromEntries(checkpoint.buckets.map((b) => [b.bucket, b.count])); + // The two parameters could be replaced with one, but: https://github.com/powersync-ja/better-sqlite3/pull/6 + const jsonBucketCount = JSON.stringify(bucketToCount); + await tx.execute( + "UPDATE ps_buckets SET count_since_last = 0, count_at_last = ?->name WHERE name != '$local' AND ?->name IS NOT NULL", + [jsonBucketCount, jsonBucketCount] + ); + } + + return true; + } else { + return false; + } }); } diff --git a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts index 705f96426..a6179a039 100644 --- a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +++ b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts @@ -20,6 +20,7 @@ import { isStreamingSyncData } from './streaming-sync-types.js'; import { DataStream } from 'src/utils/DataStream.js'; +import { InternalProgressInformation } from 'src/db/crud/SyncProgress.js'; export enum LockType { CRUD = 'crud', @@ -424,7 +425,8 @@ The next upload iteration will be delayed.`); connected: false, connecting: false, dataFlow: { - downloading: false + downloading: false, + downloadProgress: null } }); }); @@ -497,10 +499,7 @@ The next upload iteration will be delayed.`); return [req, localDescriptions]; } - protected async streamingSyncIteration( - signal: AbortSignal, - options?: PowerSyncConnectionOptions - ): Promise { + protected async streamingSyncIteration(signal: AbortSignal, options?: PowerSyncConnectionOptions): Promise { await this.obtainLock({ type: LockType.SYNC, signal, @@ -580,6 +579,7 @@ The next upload iteration will be delayed.`); bucketMap = newBuckets; await this.options.adapter.removeBuckets([...bucketsToDelete]); await this.options.adapter.setTargetCheckpoint(targetCheckpoint); + await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint); } else if (isStreamingSyncCheckpointComplete(line)) { const result = await this.applyCheckpoint(targetCheckpoint!, signal); if (result.endIteration) { @@ -640,6 +640,7 @@ The next upload iteration will be delayed.`); write_checkpoint: diff.write_checkpoint }; targetCheckpoint = newCheckpoint; + await this.updateSyncStatusForStartingCheckpoint(targetCheckpoint); bucketMap = new Map(); newBuckets.forEach((checksum, name) => @@ -657,9 +658,23 @@ The next upload iteration will be delayed.`); await this.options.adapter.setTargetCheckpoint(targetCheckpoint); } else if (isStreamingSyncData(line)) { const { data } = line; + const previousProgress = this.syncStatus.dataFlowStatus.downloadProgress; + let updatedProgress: InternalProgressInformation | null = null; + if (previousProgress) { + updatedProgress = { ...previousProgress }; + const progressForBucket = updatedProgress[data.bucket]; + if (progressForBucket) { + updatedProgress[data.bucket] = { + ...progressForBucket, + sinceLast: progressForBucket.sinceLast + data.data.length + }; + } + } + this.updateSyncStatus({ dataFlow: { - downloading: true + downloading: true, + downloadProgress: updatedProgress } }); await this.options.adapter.saveSyncData({ buckets: [SyncDataBucket.fromRow(data)] }); @@ -673,7 +688,7 @@ The next upload iteration will be delayed.`); * (uses the same one), this should have some delay. */ await this.delayRetry(); - return ; + return; } this.triggerCrudUpload(); } else { @@ -705,6 +720,30 @@ The next upload iteration will be delayed.`); }); } + private async updateSyncStatusForStartingCheckpoint(checkpoint: Checkpoint) { + const localProgress = await this.options.adapter.getBucketOperationProgress(); + const progress: InternalProgressInformation = {}; + + for (const bucket of checkpoint.buckets) { + const savedProgress = localProgress[bucket.bucket]; + progress[bucket.bucket] = { + // The fallback priority doesn't matter here, but 3 is the one newer versions of the sync service + // will use by default. + priority: bucket.priority ?? 3, + atLast: savedProgress?.atLast ?? 0, + sinceLast: savedProgress?.sinceLast ?? 0, + targetCount: bucket.count ?? 0 + }; + } + + this.updateSyncStatus({ + dataFlow: { + downloading: true, + downloadProgress: progress + } + }); + } + private async applyCheckpoint(checkpoint: Checkpoint, abort: AbortSignal) { let result = await this.options.adapter.syncLocalDatabase(checkpoint); const pending = this.pendingCrudUpload; @@ -739,6 +778,7 @@ The next upload iteration will be delayed.`); lastSyncedAt: new Date(), dataFlow: { downloading: false, + downloadProgress: null, downloadError: undefined } }); diff --git a/packages/common/src/client/sync/stream/streaming-sync-types.ts b/packages/common/src/client/sync/stream/streaming-sync-types.ts index 451520f42..bbc4b2f75 100644 --- a/packages/common/src/client/sync/stream/streaming-sync-types.ts +++ b/packages/common/src/client/sync/stream/streaming-sync-types.ts @@ -102,7 +102,7 @@ export interface StreamingSyncCheckpointDiff { last_op_id: OpId; updated_buckets: BucketChecksum[]; removed_buckets: string[]; - write_checkpoint: string; + write_checkpoint?: string; }; } diff --git a/packages/common/src/db/crud/SyncProgress.ts b/packages/common/src/db/crud/SyncProgress.ts new file mode 100644 index 000000000..eae2a6dad --- /dev/null +++ b/packages/common/src/db/crud/SyncProgress.ts @@ -0,0 +1,107 @@ +import type { SyncStatus } from './SyncStatus.js'; + +// (bucket, progress) pairs +/** @internal */ +export type InternalProgressInformation = Record< + string, + { + priority: number; // Priority of the associated buckets + atLast: number; // Total ops at last completed sync, or 0 + sinceLast: number; // Total ops _since_ the last completed sync. + targetCount: number; // Total opcount for next checkpoint as indicated by service. + } +>; + +/** + * @internal The priority used by the core extension to indicate that a full sync was completed. + */ +export const FULL_SYNC_PRIORITY = 2147483647; + +/** + * Information about a progressing download made by the PowerSync SDK. + * + * To obtain these values, use {@link SyncProgress}, available through + * {@link SyncStatus#downloadProgress}. + */ +export interface ProgressWithOperations { + /** + * The total amount of operations to download for the current sync iteration + * to complete. + */ + totalOperations: number; + /** + * The amount of operations that have already been downloaded. + */ + downloadedOperations: number; + + /** + * Relative progress, as {@link downloadedOperations} of {@link totalOperations}. + * + * This will be a number between `0.0` and `1.0` (inclusive). + * + * When this number reaches `1.0`, all changes have been received from the sync service. + * Actually applying these changes happens before the `downloadProgress` field is cleared from + * {@link SyncStatus}, so progress can stay at `1.0` for a short while before completing. + */ + downloadedFraction: number; +} + +/** + * Provides realtime progress on how PowerSync is downloading rows. + * + * The progress until the next complete sync is available through the fields on {@link ProgressWithOperations}, + * which this class implements. + * Additionally, the {@link SyncProgress.untilPriority} method can be used to otbain progress towards + * a specific priority (instead of the progress for the entire download). + * + * The reported progress always reflects the status towards th end of a sync iteration (after + * which a consistent snapshot of all buckets is available locally). + * + * In rare cases (in particular, when a [compacting](https://docs.powersync.com/usage/lifecycle-maintenance/compacting-buckets) + * operation takes place between syncs), it's possible for the returned numbers to be slightly + * inaccurate. For this reason, {@link SyncProgress} should be seen as an approximation of progress. + * The information returned is good enough to build progress bars, but not exact enough to track + * individual download counts. + * + * Also note that data is downloaded in bulk, which means that individual counters are unlikely + * to be updated one-by-one. + */ +export class SyncProgress implements ProgressWithOperations { + totalOperations: number; + downloadedOperations: number; + downloadedFraction: number; + + constructor(protected internal: InternalProgressInformation) { + const untilCompletion = this.untilPriority(FULL_SYNC_PRIORITY); + + this.totalOperations = untilCompletion.totalOperations; + this.downloadedOperations = untilCompletion.downloadedOperations; + this.downloadedFraction = untilCompletion.downloadedFraction; + } + + /** + * Returns download progress towards all data up until the specified priority being received. + * + * The returned {@link ProgressWithOperations} tracks the target amount of operations that need + * to be downloaded in total and how many of them have already been received. + */ + untilPriority(priority: number): ProgressWithOperations { + let total = 0; + let downloaded = 0; + + for (const progress of Object.values(this.internal)) { + // Include higher-priority buckets, which are represented by lower numbers. + if (progress.priority <= priority) { + downloaded += progress.sinceLast; + total += progress.targetCount - progress.atLast; + } + } + + let progress = total == 0 ? 0.0 : downloaded / total; + return { + totalOperations: total, + downloadedOperations: downloaded, + downloadedFraction: progress + }; + } +} diff --git a/packages/common/src/db/crud/SyncStatus.ts b/packages/common/src/db/crud/SyncStatus.ts index 2349a132e..f01211eaa 100644 --- a/packages/common/src/db/crud/SyncStatus.ts +++ b/packages/common/src/db/crud/SyncStatus.ts @@ -1,3 +1,5 @@ +import { InternalProgressInformation, SyncProgress } from './SyncProgress.js'; + export type SyncDataFlowStatus = Partial<{ downloading: boolean; uploading: boolean; @@ -12,6 +14,12 @@ export type SyncDataFlowStatus = Partial<{ * Cleared on the next successful upload. */ uploadError?: Error; + /** + * Internal information about how far we are downloading operations in buckets. + * + * Please use the {@link SyncStatus#downloadProgress} property to track sync progress. + */ + downloadProgress: InternalProgressInformation | null; }>; export interface SyncPriorityStatus { @@ -34,7 +42,7 @@ export class SyncStatus { /** * Indicates if the client is currently connected to the PowerSync service. - * + * * @returns {boolean} True if connected, false otherwise. Defaults to false if not specified. */ get connected() { @@ -43,7 +51,7 @@ export class SyncStatus { /** * Indicates if the client is in the process of establishing a connection to the PowerSync service. - * + * * @returns {boolean} True if connecting, false otherwise. Defaults to false if not specified. */ get connecting() { @@ -53,7 +61,7 @@ export class SyncStatus { /** * Time that a last sync has fully completed, if any. * This timestamp is reset to null after a restart of the PowerSync service. - * + * * @returns {Date | undefined} The timestamp of the last successful sync, or undefined if no sync has completed. */ get lastSyncedAt() { @@ -62,7 +70,7 @@ export class SyncStatus { /** * Indicates whether there has been at least one full sync completed since initialization. - * + * * @returns {boolean | undefined} True if at least one sync has completed, false if no sync has completed, * or undefined when the state is still being loaded from the database. */ @@ -72,7 +80,7 @@ export class SyncStatus { /** * Provides the current data flow status regarding uploads and downloads. - * + * * @returns {SyncDataFlowStatus} An object containing: * - downloading: True if actively downloading changes (only when connected is also true) * - uploading: True if actively uploading changes @@ -96,7 +104,7 @@ export class SyncStatus { /** * Provides sync status information for all bucket priorities, sorted by priority (highest first). - * + * * @returns {SyncPriorityStatus[]} An array of status entries for different sync priority levels, * sorted with highest priorities (lower numbers) first. */ @@ -105,18 +113,33 @@ export class SyncStatus { } /** - * Reports the sync status (a pair of {@link SyncStatus#hasSynced} and {@link SyncStatus#lastSyncedAt} fields) + * A realtime progress report on how many operations have been downloaded and + * how many are necessary in total to complete the next sync iteration. + * + * This field is only set when {@link SyncDataFlowStatus#downloading} is also true. + */ + get downloadProgress(): SyncProgress | null { + const internalProgress = this.options.dataFlow?.downloadProgress; + if (internalProgress == null) { + return null; + } + + return new SyncProgress(internalProgress); + } + + /** + * Reports the sync status (a pair of {@link SyncStatus#hasSynced} and {@link SyncStatus#lastSyncedAt} fields) * for a specific bucket priority level. - * + * * When buckets with different priorities are declared, PowerSync may choose to synchronize higher-priority * buckets first. When a consistent view over all buckets for all priorities up until the given priority is * reached, PowerSync makes data from those buckets available before lower-priority buckets have finished * syncing. - * - * This method returns the status for the requested priority or the next higher priority level that has - * status information available. This is because when PowerSync makes data for a given priority available, + * + * This method returns the status for the requested priority or the next higher priority level that has + * status information available. This is because when PowerSync makes data for a given priority available, * all buckets in higher-priorities are guaranteed to be consistent with that checkpoint. - * + * * For example, if PowerSync just finished synchronizing buckets in priority level 3, calling this method * with a priority of 1 may return information for priority level 3. * @@ -143,7 +166,7 @@ export class SyncStatus { /** * Compares this SyncStatus instance with another to determine if they are equal. * Equality is determined by comparing the serialized JSON representation of both instances. - * + * * @param {SyncStatus} status The SyncStatus instance to compare against * @returns {boolean} True if the instances are considered equal, false otherwise */ @@ -154,7 +177,7 @@ export class SyncStatus { /** * Creates a human-readable string representation of the current sync status. * Includes information about connection state, sync completion, and data flow. - * + * * @returns {string} A string representation of the sync status */ getMessage() { @@ -164,7 +187,7 @@ export class SyncStatus { /** * Serializes the SyncStatus instance to a plain object. - * + * * @returns {SyncStatusOptions} A plain object representation of the sync status */ toJSON(): SyncStatusOptions { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 667cd7232..96fdb08c1 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -19,6 +19,7 @@ export * from './client/sync/stream/AbstractStreamingSyncImplementation.js'; export * from './client/sync/stream/streaming-sync-types.js'; export { MAX_OP_ID } from './client/constants.js'; +export { ProgressWithOperations, SyncProgress } from './db/crud/SyncProgress.js'; export * from './db/crud/SyncStatus.js'; export * from './db/crud/UploadQueueStatus.js'; export * from './db/schema/Schema.js'; diff --git a/packages/node/src/db/BetterSQLite3DBAdapter.ts b/packages/node/src/db/BetterSQLite3DBAdapter.ts index 2b689152c..83caa716a 100644 --- a/packages/node/src/db/BetterSQLite3DBAdapter.ts +++ b/packages/node/src/db/BetterSQLite3DBAdapter.ts @@ -64,7 +64,9 @@ export class BetterSQLite3DBAdapter extends BaseObserver impl } if (!directoryExists) { - throw new Error(`The dbLocation directory at "${this.options.dbLocation}" does not exist. Please create it before opening the PowerSync database!`); + throw new Error( + `The dbLocation directory at "${this.options.dbLocation}" does not exist. Please create it before opening the PowerSync database!` + ); } dbFilePath = path.join(this.options.dbLocation, dbFilePath); diff --git a/packages/node/src/db/PowerSyncDatabase.ts b/packages/node/src/db/PowerSyncDatabase.ts index c00a44f39..bbabad46e 100644 --- a/packages/node/src/db/PowerSyncDatabase.ts +++ b/packages/node/src/db/PowerSyncDatabase.ts @@ -1,9 +1,11 @@ import { AbstractPowerSyncDatabase, + AbstractRemoteOptions, AbstractStreamingSyncImplementation, AdditionalConnectionOptions, BucketStorageAdapter, DBAdapter, + DEFAULT_REMOTE_LOGGER, PowerSyncBackendConnector, PowerSyncConnectionOptions, PowerSyncDatabaseOptions, @@ -22,6 +24,12 @@ import { Dispatcher } from 'undici'; export type NodePowerSyncDatabaseOptions = PowerSyncDatabaseOptions & { database: DBAdapter | SQLOpenFactory | NodeSQLOpenOptions; + /** + * Options to override how the SDK will connect to the sync service. + * + * This option is intended to be used for internal tests. + */ + remoteOptions?: Partial; }; export type NodeAdditionalConnectionOptions = AdditionalConnectionOptions & { @@ -79,7 +87,10 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { connector: PowerSyncBackendConnector, options: NodeAdditionalConnectionOptions ): AbstractStreamingSyncImplementation { - const remote = new NodeRemote(connector, this.options.logger, { dispatcher: options.dispatcher }); + const remote = new NodeRemote(connector, this.options.logger, { + dispatcher: options.dispatcher, + ...(this.options as NodePowerSyncDatabaseOptions).remoteOptions + }); return new NodeStreamingSyncImplementation({ adapter: this.bucketStorageAdapter, diff --git a/packages/node/src/db/RemoteConnection.ts b/packages/node/src/db/RemoteConnection.ts index aa61d39c8..fc8d94f4d 100644 --- a/packages/node/src/db/RemoteConnection.ts +++ b/packages/node/src/db/RemoteConnection.ts @@ -19,18 +19,43 @@ export class RemoteConnection implements LockContext { this.database = database; } - async executeBatch(query: string, params: any[][] = []): Promise { - const result = await this.database.executeBatch(query, params ?? []); - return RemoteConnection.wrapQueryResult(result); + /** + * Runs the inner function, but appends the stack trace where this function was called. This is useful for workers + * because stack traces from worker errors are otherwise unrelated to the application issue that has caused them. + */ + private async recoverTrace(inner: () => Promise): Promise { + const trace = {}; + Error.captureStackTrace(trace); + + try { + return await inner(); + } catch (e) { + if (e instanceof Error && e.stack) { + e.stack += (trace as any).stack; + } + + throw e; + } + } + + executeBatch(query: string, params: any[][] = []): Promise { + return this.recoverTrace(async () => { + const result = await this.database.executeBatch(query, params ?? []); + return RemoteConnection.wrapQueryResult(result); + }); } - async execute(query: string, params?: any[] | undefined): Promise { - const result = await this.database.execute(query, params ?? []); - return RemoteConnection.wrapQueryResult(result); + execute(query: string, params?: any[] | undefined): Promise { + return this.recoverTrace(async () => { + const result = await this.database.execute(query, params ?? []); + return RemoteConnection.wrapQueryResult(result); + }); } - async executeRaw(query: string, params?: any[] | undefined): Promise { - return await this.database.executeRaw(query, params ?? []); + executeRaw(query: string, params?: any[] | undefined): Promise { + return this.recoverTrace(async () => { + return await this.database.executeRaw(query, params ?? []); + }); } async getAll(sql: string, parameters?: any[]): Promise { diff --git a/packages/node/src/sync/stream/NodeRemote.ts b/packages/node/src/sync/stream/NodeRemote.ts index 75c6675da..3e8e3e18d 100644 --- a/packages/node/src/sync/stream/NodeRemote.ts +++ b/packages/node/src/sync/stream/NodeRemote.ts @@ -37,11 +37,11 @@ export class NodeRemote extends AbstractRemote { const dispatcher = options?.dispatcher ?? new EnvHttpProxyAgent(); super(connector, logger, { - ...(options ?? {}), fetchImplementation: options?.fetchImplementation ?? new NodeFetchProvider(), fetchOptions: { dispatcher - } + }, + ...(options ?? {}) }); } diff --git a/packages/node/tests/PowerSyncDatabase.test.ts b/packages/node/tests/PowerSyncDatabase.test.ts index 992864f26..28ab4b31f 100644 --- a/packages/node/tests/PowerSyncDatabase.test.ts +++ b/packages/node/tests/PowerSyncDatabase.test.ts @@ -1,5 +1,4 @@ import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; import { Worker } from 'node:worker_threads'; import { vi, expect, test } from 'vitest'; @@ -96,7 +95,7 @@ databaseTest('can watch tables', async ({ database }) => { tempDirectoryTest('throws error if target directory does not exist', async ({ tmpdir }) => { const directory = path.join(tmpdir, 'some', 'nested', 'location', 'that', 'does', 'not', 'exist'); - expect(async () => { + await expect(async () => { const database = new PowerSyncDatabase({ schema: AppSchema, database: { diff --git a/packages/node/tests/sync.test.ts b/packages/node/tests/sync.test.ts new file mode 100644 index 000000000..4eff5523f --- /dev/null +++ b/packages/node/tests/sync.test.ts @@ -0,0 +1,264 @@ +import { describe, vi, expect, beforeEach } from 'vitest'; + +import { MockSyncService, mockSyncServiceTest, TestConnector, waitForSyncStatus } from './utils'; +import { + AbstractPowerSyncDatabase, + BucketChecksum, + OplogEntryJSON, + ProgressWithOperations, + SyncStreamConnectionMethod +} from '@powersync/common'; +import Logger from 'js-logger'; + +Logger.useDefaults({ defaultLevel: Logger.WARN }); + +describe('Sync', () => { + describe('reports progress', () => { + let lastOpId = 0; + + beforeEach(() => { + lastOpId = 0; + }); + + function pushDataLine(service: MockSyncService, bucket: string, amount: number) { + const data: OplogEntryJSON[] = []; + for (let i = 0; i < amount; i++) { + data.push({ + op_id: `${++lastOpId}`, + op: 'PUT', + object_type: bucket, + object_id: `${lastOpId}`, + checksum: 0, + data: '{}' + }); + } + + service.pushLine({ + data: { + bucket, + data + } + }); + } + + function pushCheckpointComplete(service: MockSyncService, priority?: number) { + if (priority != null) { + service.pushLine({ + partial_checkpoint_complete: { + last_op_id: `${lastOpId}`, + priority + } + }); + } else { + service.pushLine({ + checkpoint_complete: { + last_op_id: `${lastOpId}` + } + }); + } + } + + mockSyncServiceTest('without priorities', async ({ syncService }) => { + const database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + syncService.pushLine({ + checkpoint: { + last_op_id: '10', + buckets: [bucket('a', 10)] + } + }); + + await waitForProgress(database, [0, 10]); + + pushDataLine(syncService, 'a', 10); + await waitForProgress(database, [10, 10]); + + pushCheckpointComplete(syncService); + await waitForSyncStatus(database, (s) => s.downloadProgress == null); + + // Emit new data, progress should be 0/2 instead of 10/12 + syncService.pushLine({ + checkpoint_diff: { + last_op_id: '12', + updated_buckets: [bucket('a', 12)], + removed_buckets: [] + } + }); + await waitForProgress(database, [0, 2]); + pushDataLine(syncService, 'a', 2); + await waitForProgress(database, [2, 2]); + pushCheckpointComplete(syncService); + await waitForSyncStatus(database, (s) => s.downloadProgress == null); + }); + + mockSyncServiceTest('interrupted sync', async ({ syncService }) => { + let database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + syncService.pushLine({ + checkpoint: { + last_op_id: '10', + buckets: [bucket('a', 10)] + } + }); + + await waitForProgress(database, [0, 10]); + pushDataLine(syncService, 'a', 5); + await waitForProgress(database, [5, 10]); + + // Close this database before sending the checkpoint... + await database.close(); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(0)); + + // And open a new one + database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + // Send same checkpoint again + syncService.pushLine({ + checkpoint: { + last_op_id: '10', + buckets: [bucket('a', 10)] + } + }); + + // Progress should be restored instead of e.g. saying 0/5 now. + await waitForProgress(database, [5, 10]); + pushCheckpointComplete(syncService); + await waitForSyncStatus(database, (s) => s.downloadProgress == null); + }); + + mockSyncServiceTest('interrupted sync with new checkpoint', async ({ syncService }) => { + let database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + syncService.pushLine({ + checkpoint: { + last_op_id: '10', + buckets: [bucket('a', 10)] + } + }); + + await waitForProgress(database, [0, 10]); + pushDataLine(syncService, 'a', 5); + await waitForProgress(database, [5, 10]); + + // Re-open database + await database.close(); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(0)); + database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + // Send checkpoint with new data + syncService.pushLine({ + checkpoint: { + last_op_id: '12', + buckets: [bucket('a', 12)] + } + }); + + await waitForProgress(database, [5, 12]); + pushCheckpointComplete(syncService); + await waitForSyncStatus(database, (s) => s.downloadProgress == null); + }); + + mockSyncServiceTest('different priorities', async ({ syncService }) => { + let database = await syncService.createDatabase(); + database.connect(new TestConnector(), { connectionMethod: SyncStreamConnectionMethod.HTTP }); + await vi.waitFor(() => expect(syncService.connectedListeners).toEqual(1)); + + syncService.pushLine({ + checkpoint: { + last_op_id: '10', + buckets: [ + bucket('a', 5, {priority: 0}), + bucket('b', 5, {priority: 2}), + ] + } + }); + + // Should be at 0/10 for total progress (which is the same as the progress for prio 2), and a 0/5 towards prio 0. + await waitForProgress(database, [0, 10], [[0, [0, 5]], [2, [0, 10]]]); + + pushDataLine(syncService, 'a', 5); + await waitForProgress(database, [5, 10], [[0, [5, 5]], [2, [5, 10]]]); + + pushCheckpointComplete(syncService, 0); + await waitForProgress(database, [5, 10], [[0, [5, 5]], [2, [5, 10]]]); + + pushDataLine(syncService, 'b', 2); + await waitForProgress(database, [7, 10], [[0, [5, 5]], [2, [7, 10]]]); + + // Before syncing b fully, send a new checkpoint + syncService.pushLine({ + checkpoint: { + last_op_id: '14', + buckets: [ + bucket('a', 8, {priority: 0}), + bucket('b', 6, {priority: 2}), + ] + } + }); + await waitForProgress(database, [7, 14], [[0, [5, 8]], [2, [7, 14]]]); + + pushDataLine(syncService, 'a', 3); + await waitForProgress(database, [10, 14], [[0, [8, 8]], [2, [10, 14]]]); + + pushCheckpointComplete(syncService, 0); + await waitForProgress(database, [10, 14], [[0, [8, 8]], [2, [10, 14]]]); + + pushDataLine(syncService, 'b', 4); + await waitForProgress(database, [14, 14], [[0, [8, 8]], [2, [14, 14]]]); + + pushCheckpointComplete(syncService); + await waitForSyncStatus(database, (s) => s.downloadProgress == null); + }); + }); +}); + +function bucket(name: string, count: number, options: {priority: number} = {priority: 3}): BucketChecksum { + return { + bucket: name, + count, + checksum: 0, + priority: options.priority, + }; +} + +async function waitForProgress( + database: AbstractPowerSyncDatabase, + total: [number, number], + forPriorities: [number, [number, number]][] = [] +) { + await waitForSyncStatus(database, (status) => { + const progress = status.downloadProgress; + if (!progress) { + return false; + } + + //console.log('checking', progress); + + const check = (expected: [number, number], actual: ProgressWithOperations): boolean => { + return actual.downloadedOperations == expected[0] && actual.totalOperations == expected[1]; + }; + + if (!check(total, progress)) { + return false; + } + + for (const [priority, expected] of forPriorities) { + if (!check(expected, progress.untilPriority(priority))) { + //console.log('failed for', priority, expected, progress); + return false; + } + } + + return true; + }); +} diff --git a/packages/node/tests/utils.ts b/packages/node/tests/utils.ts index bd4eb1951..5c2c28988 100644 --- a/packages/node/tests/utils.ts +++ b/packages/node/tests/utils.ts @@ -1,8 +1,20 @@ import os from 'node:os'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { test } from 'vitest'; -import { column, PowerSyncDatabase, Schema, Table } from '../lib'; +import { onTestFinished, test } from 'vitest'; +import { + AbstractPowerSyncDatabase, + AbstractRemoteOptions, + column, + NodePowerSyncDatabaseOptions, + PowerSyncBackendConnector, + PowerSyncCredentials, + PowerSyncDatabase, + Schema, + StreamingSyncLine, + SyncStatus, + Table +} from '../lib'; export async function createTempDir() { const ostmpdir = os.tmpdir(); @@ -37,15 +49,124 @@ export const tempDirectoryTest = test.extend<{ tmpdir: string }>({ } }); +async function createDatabase( + tmpdir: string, + options: Partial = {} +): Promise { + const database = new PowerSyncDatabase({ + ...options, + schema: AppSchema, + database: { + dbFilename: 'test.db', + dbLocation: tmpdir + } + }); + await database.init(); + return database; +} + export const databaseTest = tempDirectoryTest.extend<{ database: PowerSyncDatabase }>({ database: async ({ tmpdir }, use) => { - const database = new PowerSyncDatabase({ - schema: AppSchema, - database: { - dbFilename: 'test.db', - dbLocation: tmpdir + const db = await createDatabase(tmpdir); + await use(db); + await db.close(); + } +}); + +// TODO: Unify this with the test setup for the web SDK. +export const mockSyncServiceTest = tempDirectoryTest.extend<{ syncService: MockSyncService }>({ + syncService: async ({ tmpdir }, use) => { + const listeners: ReadableStreamDefaultController[] = []; + + const inMemoryFetch: typeof fetch = async (info, init?) => { + const request = new Request(info, init); + if (request.url.endsWith('/sync/stream')) { + let thisController: ReadableStreamDefaultController | null = null; + + const syncLines = new ReadableStream({ + start(controller) { + thisController = controller; + listeners.push(controller); + }, + cancel() { + listeners.splice(listeners.indexOf(thisController!), 1); + } + }); + + const encoder = new TextEncoder(); + const asLines = new TransformStream({ + transform: (chunk, controller) => { + const line = `${JSON.stringify(chunk)}\n`; + controller.enqueue(encoder.encode(line)); + } + }); + + return new Response(syncLines.pipeThrough(asLines), { status: 200 }); + } else { + return new Response('Not found', { status: 404 }); } + }; + + const newConnection = async () => { + const db = await createDatabase(tmpdir, { + remoteOptions: { + fetchImplementation: inMemoryFetch + } + }); + + onTestFinished(async () => await db.close()); + return db; + }; + + await use({ + get connectedListeners() { + return listeners.length; + }, + pushLine(line) { + for (const listener of listeners) { + listener.enqueue(line); + } + }, + createDatabase: newConnection }); - await use(database); } }); + +export interface MockSyncService { + pushLine: (line: StreamingSyncLine) => void; + connectedListeners: number; + createDatabase: () => Promise; +} + +export class TestConnector implements PowerSyncBackendConnector { + async fetchCredentials(): Promise { + return { + endpoint: 'https://powersync.example.org', + token: 'test' + }; + } + async uploadData(database: AbstractPowerSyncDatabase): Promise { + const tx = await database.getNextCrudTransaction(); + await tx?.complete(); + } +} + +export function waitForSyncStatus( + database: AbstractPowerSyncDatabase, + matcher: (status: SyncStatus) => boolean +): Promise { + return new Promise((resolve) => { + if (matcher(database.currentStatus)) { + return resolve(); + } + + const dispose = database.registerListener({ + statusChanged: (status) => { + if (matcher(status)) { + dispose(); + resolve(); + } + } + }); + }); +} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 085f16775..a928e2e6a 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -30,7 +30,7 @@ }, "homepage": "https://docs.powersync.com/", "peerDependencies": { - "@journeyapps/react-native-quick-sqlite": "^2.4.3", + "@journeyapps/react-native-quick-sqlite": "^2.4.4", "@powersync/common": "workspace:^1.28.0", "react": "*", "react-native": "*" @@ -46,7 +46,7 @@ }, "devDependencies": { "@craftzdog/react-native-buffer": "^6.0.5", - "@journeyapps/react-native-quick-sqlite": "^2.4.3", + "@journeyapps/react-native-quick-sqlite": "^2.4.4", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-inject": "^5.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 868f1fa10..a77a309c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,8 +121,8 @@ importers: specifier: ^14.0.0 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.4.3 - version: 2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + specifier: ^2.4.4 + version: 2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@powersync/common': specifier: workspace:* version: link:../../packages/common @@ -392,7 +392,7 @@ importers: version: 10.4.20(postcss@8.5.3) babel-loader: specifier: ^9.1.3 - version: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) + version: 9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))) electron: specifier: 30.0.2 version: 30.0.2 @@ -804,8 +804,8 @@ importers: specifier: 8.3.1 version: 8.3.1 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.4.3 - version: 2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + specifier: ^2.4.4 + version: 2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@powersync/common': specifier: workspace:* version: link:../../packages/common @@ -937,8 +937,8 @@ importers: specifier: ^14.0.3 version: 14.0.4 '@journeyapps/react-native-quick-sqlite': - specifier: ^2.4.3 - version: 2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + specifier: ^2.4.4 + version: 2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@powersync/attachments': specifier: workspace:* version: link:../../packages/attachments @@ -1077,7 +1077,7 @@ importers: version: 14.0.4 '@journeyapps/react-native-quick-sqlite': specifier: ^2.4.0 - version: 2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + version: 2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@journeyapps/wa-sqlite': specifier: ^1.2.0 version: 1.2.4 @@ -1889,8 +1889,8 @@ importers: specifier: ^6.0.5 version: 6.0.5(react-native@0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@journeyapps/react-native-quick-sqlite': - specifier: ^2.4.3 - version: 2.4.3(react-native@0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + specifier: ^2.4.4 + version: 2.4.4(react-native@0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1))(react@18.3.1) '@rollup/plugin-alias': specifier: ^5.1.0 version: 5.1.1(rollup@4.14.3) @@ -5103,8 +5103,8 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@journeyapps/react-native-quick-sqlite@2.4.3': - resolution: {integrity: sha512-w9KOid3upSyJfrScvKjtxEfnMkmEvcyPF1cWwKApM7a7NKQs8Vkth89NsnHs4k1M0Ktpm2eM26cR2/Qply+yfQ==} + '@journeyapps/react-native-quick-sqlite@2.4.4': + resolution: {integrity: sha512-XoAGByoyxJqKNTh0lqakRb/A8uZwksbP4KaYXqcEmeGyOBxhOdjBDR15pkCIMoJU1JTBzU8LXxQRtx20+7jCWg==} peerDependencies: react: '*' react-native: '*' @@ -24721,17 +24721,17 @@ snapshots: '@types/yargs': 17.0.33 chalk: 4.1.2 - '@journeyapps/react-native-quick-sqlite@2.4.3(react-native@0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': + '@journeyapps/react-native-quick-sqlite@2.4.4(react-native@0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-native: 0.72.4(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(encoding@0.1.13)(react@18.3.1) - '@journeyapps/react-native-quick-sqlite@2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': + '@journeyapps/react-native-quick-sqlite@2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.3.3))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1) - '@journeyapps/react-native-quick-sqlite@2.4.3(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': + '@journeyapps/react-native-quick-sqlite@2.4.4(react-native@0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-native: 0.76.9(@babel/core@7.26.10)(@babel/preset-env@7.26.9(@babel/core@7.26.10))(@react-native-community/cli@15.1.3(typescript@5.8.2))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1) @@ -31010,6 +31010,13 @@ snapshots: transitivePeerDependencies: - supports-color + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5))): + dependencies: + '@babel/core': 7.26.10 + find-cache-dir: 4.0.0 + schema-utils: 4.2.0 + webpack: 5.95.0(@swc/core@1.6.13(@swc/helpers@0.5.5)) + babel-loader@9.2.1(@babel/core@7.26.10)(webpack@5.98.0(@swc/core@1.10.1(@swc/helpers@0.5.5))): dependencies: '@babel/core': 7.26.10 @@ -35428,7 +35435,7 @@ snapshots: entities: 4.5.0 param-case: 3.0.4 relateurl: 0.2.7 - terser: 5.34.1 + terser: 5.39.0 html-tags@3.3.1: {} @@ -37912,7 +37919,7 @@ snapshots: metro-minify-terser@0.80.12: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.34.1 + terser: 5.39.0 metro-minify-terser@0.81.3: dependencies: @@ -40284,8 +40291,8 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.7 - picocolors: 1.1.0 + nanoid: 3.3.8 + picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.4.47: