diff --git a/.changeset/neat-bears-listen.md b/.changeset/neat-bears-listen.md new file mode 100644 index 000000000..7ac6bdc40 --- /dev/null +++ b/.changeset/neat-bears-listen.md @@ -0,0 +1,5 @@ +--- +'@powersync/node': patch +--- + +DB operations will now throw a dedicated `ConnectionClosed` error when an attempt to perform an operation on a closed connection is made. diff --git a/.changeset/rare-windows-argue.md b/.changeset/rare-windows-argue.md new file mode 100644 index 000000000..d7c5548d0 --- /dev/null +++ b/.changeset/rare-windows-argue.md @@ -0,0 +1,7 @@ +--- +'@powersync/web': minor +--- + +- Fixed some edge cases where multiple tabs with OPFS can cause sync deadlocks. +- Fixed issue where calling `powerSync.close()` would cause a disconnect if using multiple tabs (the default should not be to disconnect if using multiple tabs) +- Improved shared sync implementation database delegation and opening strategy. diff --git a/.changeset/witty-steaks-worry.md b/.changeset/witty-steaks-worry.md new file mode 100644 index 000000000..f92c5e058 --- /dev/null +++ b/.changeset/witty-steaks-worry.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +- Improved serializing of upload and download errors for SyncStatus events. Some JS `Error`s are not cloneable, the JSON representation of a SyncStatus should now always be cloneable. diff --git a/demos/angular-supabase-todolist/package.json b/demos/angular-supabase-todolist/package.json index a5f2f5c4a..875e79be2 100644 --- a/demos/angular-supabase-todolist/package.json +++ b/demos/angular-supabase-todolist/package.json @@ -22,7 +22,7 @@ "@angular/platform-browser-dynamic": "^19.2.4", "@angular/router": "^19.2.4", "@angular/service-worker": "^19.2.4", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/web": "^1.30.0", "@supabase/supabase-js": "^2.44.4", "rxjs": "~7.8.1", diff --git a/demos/example-capacitor/package.json b/demos/example-capacitor/package.json index 88b1d0a37..7b67bc174 100644 --- a/demos/example-capacitor/package.json +++ b/demos/example-capacitor/package.json @@ -26,7 +26,7 @@ "@capacitor/ios": "^7.4.3", "@capacitor/splash-screen": "latest", "@powersync/capacitor": "^0.2.0", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/react": "^1.8.2", "@powersync/web": "^1.30.0", "react": "^18.2.0", diff --git a/demos/example-electron/package.json b/demos/example-electron/package.json index a01df3733..200000e8d 100644 --- a/demos/example-electron/package.json +++ b/demos/example-electron/package.json @@ -21,7 +21,7 @@ "dependencies": { "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^5.15.16", "@mui/material": "^5.15.16", "@mui/x-data-grid": "^6.19.11", diff --git a/demos/example-nextjs/package.json b/demos/example-nextjs/package.json index 1844ca123..9d9be3314 100644 --- a/demos/example-nextjs/package.json +++ b/demos/example-nextjs/package.json @@ -14,7 +14,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/roboto": "^5.0.13", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@lexical/react": "^0.15.0", "@mui/icons-material": "^5.15.18", "@mui/material": "^5.15.18", diff --git a/demos/react-multi-client/package.json b/demos/react-multi-client/package.json index fbc2a93a4..a85258fd4 100644 --- a/demos/react-multi-client/package.json +++ b/demos/react-multi-client/package.json @@ -10,7 +10,7 @@ "test:build": "pnpm build" }, "dependencies": { - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/react": "^1.8.2", "@powersync/web": "^1.30.0", "@supabase/supabase-js": "^2.43.1", diff --git a/demos/react-native-web-supabase-todolist/package.json b/demos/react-native-web-supabase-todolist/package.json index 4569324a3..267f80a57 100644 --- a/demos/react-native-web-supabase-todolist/package.json +++ b/demos/react-native-web-supabase-todolist/package.json @@ -14,7 +14,7 @@ "@expo/metro-runtime": "^4.0.1", "@expo/vector-icons": "^14.0.4", "@journeyapps/react-native-quick-sqlite": "^2.5.0", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/attachments": "^2.4.1", "@powersync/react": "^1.8.2", "@powersync/react-native": "^1.28.0", diff --git a/demos/react-supabase-todolist-optional-sync/package.json b/demos/react-supabase-todolist-optional-sync/package.json index dc2aa2986..7c48cac68 100644 --- a/demos/react-supabase-todolist-optional-sync/package.json +++ b/demos/react-supabase-todolist-optional-sync/package.json @@ -13,7 +13,7 @@ "@powersync/web": "^1.30.0", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", diff --git a/demos/react-supabase-todolist-sync-streams/package.json b/demos/react-supabase-todolist-sync-streams/package.json index a69e21087..09ae10bb4 100644 --- a/demos/react-supabase-todolist-sync-streams/package.json +++ b/demos/react-supabase-todolist-sync-streams/package.json @@ -13,7 +13,7 @@ "@powersync/web": "^1.30.0", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", diff --git a/demos/react-supabase-todolist-tanstackdb/package.json b/demos/react-supabase-todolist-tanstackdb/package.json index fb207172e..0771b8115 100644 --- a/demos/react-supabase-todolist-tanstackdb/package.json +++ b/demos/react-supabase-todolist-tanstackdb/package.json @@ -13,7 +13,7 @@ "dependencies": { "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", diff --git a/demos/react-supabase-todolist/package.json b/demos/react-supabase-todolist/package.json index a69e21087..09ae10bb4 100644 --- a/demos/react-supabase-todolist/package.json +++ b/demos/react-supabase-todolist/package.json @@ -13,7 +13,7 @@ "@powersync/web": "^1.30.0", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^5.15.12", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 6bcf627de..f4e800b5f 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -3,7 +3,14 @@ import { AppSchema, ListRecord, LISTS_TABLE, TODOS_TABLE } from '@/library/power import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { createBaseLogger, DifferentialWatchedQuery, LogLevel, PowerSyncDatabase } from '@powersync/web'; +import { + createBaseLogger, + DifferentialWatchedQuery, + LogLevel, + PowerSyncDatabase, + WASQLiteOpenFactory, + WASQLiteVFS +} from '@powersync/web'; import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; @@ -14,8 +21,15 @@ export const useSupabase = () => React.useContext(SupabaseContext); export const db = new PowerSyncDatabase({ schema: AppSchema, - database: { - dbFilename: 'example.db' + database: new WASQLiteOpenFactory({ + dbFilename: 'example.db', + vfs: WASQLiteVFS.OPFSCoopSyncVFS, + flags: { + enableMultiTabs: typeof SharedWorker !== 'undefined' + } + }), + flags: { + enableMultiTabs: typeof SharedWorker !== 'undefined' } }); diff --git a/demos/yjs-react-supabase-text-collab/package.json b/demos/yjs-react-supabase-text-collab/package.json index 64b9cac1b..295ab9970 100644 --- a/demos/yjs-react-supabase-text-collab/package.json +++ b/demos/yjs-react-supabase-text-collab/package.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", "@powersync/react": "^1.8.2", diff --git a/packages/common/src/client/ConnectionManager.ts b/packages/common/src/client/ConnectionManager.ts index 859add550..0d877d7e2 100644 --- a/packages/common/src/client/ConnectionManager.ts +++ b/packages/common/src/client/ConnectionManager.ts @@ -1,4 +1,5 @@ import { ILogger } from 'js-logger'; +import { SyncStatus } from '../db/crud/SyncStatus.js'; import { BaseListener, BaseObserver } from '../utils/BaseObserver.js'; import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js'; import { @@ -13,7 +14,6 @@ import { SyncStreamSubscribeOptions, SyncStreamSubscription } from './sync/sync-streams.js'; -import { SyncStatus } from '../db/crud/SyncStatus.js'; /** * @internal diff --git a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts index 6c610f111..ae568eb8c 100644 --- a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +++ b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts @@ -1241,7 +1241,6 @@ The next upload iteration will be delayed.`); resolve(); return; } - const { retryDelayMs } = this.options; let timeoutId: ReturnType | undefined; diff --git a/packages/common/src/db/ConnectionClosedError.ts b/packages/common/src/db/ConnectionClosedError.ts new file mode 100644 index 000000000..067ce9965 --- /dev/null +++ b/packages/common/src/db/ConnectionClosedError.ts @@ -0,0 +1,23 @@ +/** + * Thrown when an underlying database connection is closed. + * This is particularly relevant when worker connections are marked as closed while + * operations are still in progress. + */ +export class ConnectionClosedError extends Error { + static NAME = 'ConnectionClosedError'; + + static MATCHES(input: any) { + /** + * If there are weird package issues which cause multiple versions of classes to be present, the instanceof + * check might fail. This also performs a failsafe check. + * This might also happen if the Error is serialized and parsed over a bridging channel like a MessagePort. + */ + return ( + input instanceof ConnectionClosedError || (input instanceof Error && input.name == ConnectionClosedError.NAME) + ); + } + constructor(message: string) { + super(message); + this.name = ConnectionClosedError.NAME; + } +} diff --git a/packages/common/src/db/crud/SyncStatus.ts b/packages/common/src/db/crud/SyncStatus.ts index f5686c037..a8502f879 100644 --- a/packages/common/src/db/crud/SyncStatus.ts +++ b/packages/common/src/db/crud/SyncStatus.ts @@ -1,7 +1,7 @@ -import { CoreStreamSubscription } from '../../client/sync/stream/core-instruction.js'; import { SyncClientImplementation } from '../../client/sync/stream/AbstractStreamingSyncImplementation.js'; -import { InternalProgressInformation, ProgressWithOperations, SyncProgress } from './SyncProgress.js'; +import { CoreStreamSubscription } from '../../client/sync/stream/core-instruction.js'; import { SyncStreamDescription, SyncSubscriptionDescription } from '../../client/sync/sync-streams.js'; +import { InternalProgressInformation, ProgressWithOperations, SyncProgress } from './SyncProgress.js'; export type SyncDataFlowStatus = Partial<{ downloading: boolean; @@ -250,13 +250,32 @@ export class SyncStatus { return { connected: this.connected, connecting: this.connecting, - dataFlow: this.dataFlowStatus, + dataFlow: { + ...this.dataFlowStatus, + uploadError: this.serializeError(this.dataFlowStatus.uploadError), + downloadError: this.serializeError(this.dataFlowStatus.downloadError) + }, lastSyncedAt: this.lastSyncedAt, hasSynced: this.hasSynced, priorityStatusEntries: this.priorityStatusEntries }; } + /** + * Not all errors are serializable over a MessagePort. E.g. some `DomExceptions` fail to be passed across workers. + * This explicitly serializes errors in the SyncStatus. + */ + protected serializeError(error?: Error) { + if (typeof error == 'undefined') { + return undefined; + } + return { + name: error.name, + message: error.message, + stack: error.stack + }; + } + private static comparePriorities(a: SyncPriorityStatus, b: SyncPriorityStatus) { return b.priority - a.priority; // Reverse because higher priorities have lower numbers } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7173a2ece..bb43ea871 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -21,6 +21,7 @@ export * from './client/sync/stream/streaming-sync-types.js'; export * from './client/sync/sync-streams.js'; export * from './client/ConnectionManager.js'; +export * from './db/ConnectionClosedError.js'; export { ProgressWithOperations, SyncProgress } from './db/crud/SyncProgress.js'; export * from './db/crud/SyncStatus.js'; export * from './db/crud/UploadQueueStatus.js'; diff --git a/packages/drizzle-driver/package.json b/packages/drizzle-driver/package.json index 98d65a1f1..27c387365 100644 --- a/packages/drizzle-driver/package.json +++ b/packages/drizzle-driver/package.json @@ -47,7 +47,7 @@ "drizzle-orm": "<1.0.0" }, "devDependencies": { - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/web": "workspace:*", "@types/node": "^20.17.6", "drizzle-orm": "^0.44.7", @@ -55,4 +55,4 @@ "vite-plugin-top-level-await": "^1.4.4", "vite-plugin-wasm": "^3.3.0" } -} \ No newline at end of file +} diff --git a/packages/kysely-driver/package.json b/packages/kysely-driver/package.json index a67368089..bd39089b2 100644 --- a/packages/kysely-driver/package.json +++ b/packages/kysely-driver/package.json @@ -49,7 +49,7 @@ "kysely": "^0.28.0" }, "devDependencies": { - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/web": "workspace:*", "@types/node": "^20.17.6", "vite": "^6.1.0", diff --git a/packages/node/src/db/RemoteConnection.ts b/packages/node/src/db/RemoteConnection.ts index 08f335952..05c9503fe 100644 --- a/packages/node/src/db/RemoteConnection.ts +++ b/packages/node/src/db/RemoteConnection.ts @@ -1,6 +1,6 @@ -import { Worker } from 'node:worker_threads'; -import { LockContext, QueryResult } from '@powersync/common'; +import { ConnectionClosedError, LockContext, QueryResult } from '@powersync/common'; import { releaseProxy, Remote } from 'comlink'; +import { Worker } from 'node:worker_threads'; import { AsyncDatabase, AsyncDatabaseOpener, ProxiedQueryResult } from './AsyncDatabase.js'; /** @@ -36,11 +36,11 @@ export class RemoteConnection implements LockContext { return new Promise((resolve, reject) => { if (controller.signal.aborted) { - reject(new Error('Called operation on closed remote')); + reject(new ConnectionClosedError('Called operation on closed remote')); } function handleAbort() { - reject(new Error('Remote peer closed with request in flight')); + reject(new ConnectionClosedError('Remote peer closed with request in flight')); } function completePromise(action: () => void) { diff --git a/packages/web/package.json b/packages/web/package.json index 7a7c001af..a34b01abe 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -61,7 +61,7 @@ "author": "JOURNEYAPPS", "license": "Apache-2.0", "peerDependencies": { - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@powersync/common": "workspace:^1.44.0" }, "dependencies": { @@ -72,7 +72,7 @@ "commander": "^12.1.0" }, "devDependencies": { - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@types/uuid": "^9.0.6", "crypto-browserify": "^3.12.0", "p-defer": "^4.0.1", diff --git a/packages/web/src/db/PowerSyncDatabase.ts b/packages/web/src/db/PowerSyncDatabase.ts index 0e60de018..fe88ac71d 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -1,32 +1,31 @@ import { - type BucketStorageAdapter, - type PowerSyncBackendConnector, - type PowerSyncCloseOptions, - type RequiredAdditionalConnectionOptions, AbstractPowerSyncDatabase, DBAdapter, - DEFAULT_POWERSYNC_CLOSE_OPTIONS, - isDBAdapter, - isSQLOpenFactory, PowerSyncDatabaseOptions, PowerSyncDatabaseOptionsWithDBAdapter, PowerSyncDatabaseOptionsWithOpenFactory, PowerSyncDatabaseOptionsWithSettings, SqliteBucketStorage, - StreamingSyncImplementation + StreamingSyncImplementation, + isDBAdapter, + isSQLOpenFactory, + type BucketStorageAdapter, + type PowerSyncBackendConnector, + type PowerSyncCloseOptions, + type RequiredAdditionalConnectionOptions } from '@powersync/common'; import { Mutex } from 'async-mutex'; import { getNavigatorLocks } from '../shared/navigator'; +import { WebDBAdapter } from './adapters/WebDBAdapter'; import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; import { DEFAULT_WEB_SQL_FLAGS, ResolvedWebSQLOpenOptions, - resolveWebSQLFlags, - WebSQLFlags + WebSQLFlags, + resolveWebSQLFlags } from './adapters/web-sql-flags'; -import { WebDBAdapter } from './adapters/WebDBAdapter'; -import { SharedWebStreamingSyncImplementation } from './sync/SharedWebStreamingSyncImplementation'; import { SSRStreamingSyncImplementation } from './sync/SSRWebStreamingSyncImplementation'; +import { SharedWebStreamingSyncImplementation } from './sync/SharedWebStreamingSyncImplementation'; import { WebRemote } from './sync/WebRemote'; import { WebStreamingSyncImplementation, @@ -160,14 +159,13 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { * By default the sync stream client is only disconnected if * multiple tabs are not enabled. */ - close(options: PowerSyncCloseOptions = DEFAULT_POWERSYNC_CLOSE_OPTIONS): Promise { + close(options?: PowerSyncCloseOptions): Promise { if (this.unloadListener) { window.removeEventListener('unload', this.unloadListener); } - return super.close({ // Don't disconnect by default if multiple tabs are enabled - disconnect: options.disconnect ?? !this.resolvedFlags.enableMultiTabs + disconnect: options?.disconnect ?? !this.resolvedFlags.enableMultiTabs }); } diff --git a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts index 4a46e3a9c..1a495f3c5 100644 --- a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts +++ b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts @@ -1,5 +1,6 @@ import { BaseObserver, + ConnectionClosedError, DBAdapter, DBAdapterListener, DBGetUtils, @@ -10,7 +11,7 @@ import { createLogger, type ILogger } from '@powersync/common'; -import { getNavigatorLocks } from '../..//shared/navigator'; +import { getNavigatorLocks } from '../../shared/navigator'; import { AsyncDatabaseConnection } from './AsyncDatabaseConnection'; import { SharedConnectionWorker, WebDBAdapter } from './WebDBAdapter'; import { WorkerWrappedAsyncDatabaseConnection } from './WorkerWrappedAsyncDatabaseConnection'; @@ -26,10 +27,16 @@ export interface LockedAsyncDatabaseAdapterOptions { openConnection: () => Promise; debugMode?: boolean; logger?: ILogger; + defaultLockTimeoutMs?: number; + reOpenOnConnectionClosed?: boolean; } export type LockedAsyncDatabaseAdapterListener = DBAdapterListener & { initialized?: () => void; + /** + * Fired when the database is re-opened after being closed. + */ + databaseReOpened?: () => void; }; /** @@ -51,6 +58,7 @@ export class LockedAsyncDatabaseAdapter private _config: ResolvedWebSQLOpenOptions | null = null; protected pendingAbortControllers: Set; protected requiresHolds: boolean | null; + protected databaseOpenPromise: Promise | null = null; closing: boolean; closed: boolean; @@ -105,16 +113,72 @@ export class LockedAsyncDatabaseAdapter return this.initPromise; } + protected async openInternalDB() { + /** + * Execute opening of the db in a lock in order not to interfere with other operations. + */ + return this._acquireLock(async () => { + // Dispose any previous table change listener. + this._disposeTableChangeListener?.(); + this._disposeTableChangeListener = null; + this._db?.close().catch((ex) => this.logger.warn(`Error closing database before opening new instance`, ex)); + const isReOpen = !!this._db; + this._db = null; + + this._db = await this.options.openConnection(); + await this._db.init(); + this._config = await this._db.getConfig(); + await this.registerOnChangeListener(this._db); + if (isReOpen) { + this.iterateListeners((cb) => cb.databaseReOpened?.()); + } + /** + * This is only required for the long-lived shared IndexedDB connections. + */ + this.requiresHolds = (this._config as ResolvedWASQLiteOpenFactoryOptions).vfs == WASQLiteVFS.IDBBatchAtomicVFS; + }); + } + + protected _reOpen() { + this.databaseOpenPromise = this.openInternalDB().finally(() => { + this.databaseOpenPromise = null; + }); + return this.databaseOpenPromise; + } + + /** + * Re-opens the underlying database. + * Returns a pending operation if one is already in progress. + */ + async reOpenInternalDB(): Promise { + if (!this.options.reOpenOnConnectionClosed) { + throw new Error(`Cannot re-open underlying database, reOpenOnConnectionClosed is not enabled`); + } + if (this.databaseOpenPromise) { + return this.databaseOpenPromise; + } + return this._reOpen(); + } + protected async _init() { - this._db = await this.options.openConnection(); - await this._db.init(); - this._config = await this._db.getConfig(); - await this.registerOnChangeListener(this._db); - this.iterateListeners((cb) => cb.initialized?.()); /** - * This is only required for the long-lived shared IndexedDB connections. + * For OPFS, we can see this open call sometimes fail due to NoModificationAllowedError. + * We should be able to recover from this by re-opening the database. */ - this.requiresHolds = (this._config as ResolvedWASQLiteOpenFactoryOptions).vfs == WASQLiteVFS.IDBBatchAtomicVFS; + const maxAttempts = 3; + for (let count = 0; count < maxAttempts; count++) { + try { + await this.openInternalDB(); + break; + } catch (ex) { + if (count == maxAttempts - 1) { + throw ex; + } + this.logger.warn(`Attempt ${count + 1} of ${maxAttempts} to open database failed, retrying in 1 second...`, ex); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + this.iterateListeners((cb) => cb.initialized?.()); } getConfiguration(): ResolvedWebSQLOpenOptions { @@ -170,6 +234,10 @@ export class LockedAsyncDatabaseAdapter */ async close() { this.closing = true; + /** + * Note that we obtain a reference to the callback to avoid calling the callback with `this` as the context. + * This is to avoid Comlink attempting to clone `this` when calling the method. + */ const dispose = this._disposeTableChangeListener; if (dispose) { dispose(); @@ -199,7 +267,7 @@ export class LockedAsyncDatabaseAdapter return this.acquireLock( async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), { - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs } ); } @@ -209,23 +277,20 @@ export class LockedAsyncDatabaseAdapter return this.acquireLock( async () => fn(this.generateDBHelpers({ execute: this._execute, executeRaw: this._executeRaw })), { - timeoutMs: options?.timeoutMs + timeoutMs: options?.timeoutMs ?? this.options.defaultLockTimeoutMs } ); } - protected async acquireLock(callback: () => Promise, options?: { timeoutMs?: number }): Promise { - await this.waitForInitialized(); - + protected async _acquireLock(callback: () => Promise, options?: { timeoutMs?: number }): Promise { if (this.closing) { throw new Error(`Cannot acquire lock, the database is closing`); } - const abortController = new AbortController(); this.pendingAbortControllers.add(abortController); const { timeoutMs } = options ?? {}; - const timoutId = timeoutMs + const timeoutId = timeoutMs ? setTimeout(() => { abortController.abort(`Timeout after ${timeoutMs}ms`); this.pendingAbortControllers.delete(abortController); @@ -237,19 +302,52 @@ export class LockedAsyncDatabaseAdapter { signal: abortController.signal }, async () => { this.pendingAbortControllers.delete(abortController); - if (timoutId) { - clearTimeout(timoutId); + if (timeoutId) { + clearTimeout(timeoutId); } - const holdId = this.requiresHolds ? await this.baseDB.markHold() : null; - try { - return await callback(); - } finally { - if (holdId) { - await this.baseDB.releaseHold(holdId); + return await callback(); + } + ); + } + + protected async acquireLock(callback: () => Promise, options?: { timeoutMs?: number }): Promise { + await this.waitForInitialized(); + + // The database is being opened in the background. Wait for it here. + if (this.databaseOpenPromise) { + await this.databaseOpenPromise; + } + + return this._acquireLock(async () => { + let holdId: string | null = null; + try { + /** + * We can't await this since it uses the same lock as we're in now. + * If there is a pending open, this call will throw. + * If there is no pending open, but there is also no database - the open + * might have failed. We need to re-open the database. + */ + if (this.databaseOpenPromise || !this._db) { + throw new ConnectionClosedError('Connection is busy re-opening'); + } + + holdId = this.requiresHolds ? await this.baseDB.markHold() : null; + return await callback(); + } catch (ex) { + if (ConnectionClosedError.MATCHES(ex)) { + if (this.options.reOpenOnConnectionClosed && !this.databaseOpenPromise && !this.closing) { + // Immediately re-open the database. We need to miss as little table updates as possible. + // Note, don't await this since it uses the same lock as we're in now. + this.reOpenInternalDB(); } } + throw ex; + } finally { + if (holdId) { + await this.baseDB.releaseHold(holdId); + } } - ); + }, options); } async readTransaction(fn: (tx: Transaction) => Promise, options?: DBLockOptions | undefined): Promise { diff --git a/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts b/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts index e03bf8aa8..cc0528d15 100644 --- a/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts +++ b/packages/web/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts @@ -1,3 +1,4 @@ +import { BaseObserver, ConnectionClosedError } from '@powersync/common'; import * as Comlink from 'comlink'; import { AsyncDatabaseConnection, @@ -23,17 +24,23 @@ export type WrappedWorkerConnectionOptions void; }; +export type WorkerWrappedAsyncDatabaseConnectionListener = { + closing: () => void; +}; /** * Wraps a provided instance of {@link AsyncDatabaseConnection}, providing necessary proxy * functions for worker listeners. */ export class WorkerWrappedAsyncDatabaseConnection + extends BaseObserver implements AsyncDatabaseConnection { protected lockAbortController = new AbortController(); protected notifyRemoteClosed: AbortController | undefined; constructor(protected options: WrappedWorkerConnectionOptions) { + super(); + if (options.remoteCanCloseUnexpectedly) { this.notifyRemoteClosed = new AbortController(); } @@ -72,18 +79,21 @@ export class WorkerWrappedAsyncDatabaseConnection this.baseConnection.isAutoCommit()); } - private withRemote(workerPromise: () => Promise): Promise { + private withRemote(workerPromise: () => Promise, fireActionOnAbort = false): Promise { const controller = this.notifyRemoteClosed; if (controller) { return new Promise((resolve, reject) => { if (controller.signal.aborted) { - reject(new Error('Called operation on closed remote')); - // Don't run the operation if we're going to reject - return; + reject(new ConnectionClosedError('Called operation on closed remote')); + if (!fireActionOnAbort) { + // Don't run the operation if we're going to reject + // We might want to fire-and-forget the operation in some cases (like a close operation) + return; + } } function handleAbort() { - reject(new Error('Remote peer closed with request in flight')); + reject(new ConnectionClosedError('Remote peer closed with request in flight')); } function completePromise(action: () => void) { @@ -164,10 +174,12 @@ export class WorkerWrappedAsyncDatabaseConnection this.baseConnection.close()); + // fire and forget the close operation + await this.withRemote(() => this.baseConnection.close(), true); } finally { this.options.remote[Comlink.releaseProxy](); this.options.onClose?.(); + this.iterateListeners((l) => l.closing?.()); } } diff --git a/packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts b/packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts index 8860a7233..c94a4d3f0 100644 --- a/packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts +++ b/packages/web/src/db/adapters/wa-sqlite/WASQLiteConnection.ts @@ -3,7 +3,6 @@ import { BaseObserver, BatchedUpdateNotification } from '@powersync/common'; import { Mutex } from 'async-mutex'; import { AsyncDatabaseConnection, OnTableChangeCallback, ProxiedQueryResult } from '../AsyncDatabaseConnection'; import { ResolvedWASQLiteOpenFactoryOptions } from './WASQLiteOpenFactory'; - /** * List of currently tested virtual filesystems */ @@ -126,9 +125,10 @@ export const DEFAULT_MODULE_FACTORIES = { } // @ts-expect-error The types for this static method are missing upstream const { OPFSCoopSyncVFS } = await import('@journeyapps/wa-sqlite/src/examples/OPFSCoopSyncVFS.js'); + const vfs = await OPFSCoopSyncVFS.create(options.dbFileName, module); return { module, - vfs: await OPFSCoopSyncVFS.create(options.dbFileName, module) + vfs }; } }; @@ -387,7 +387,15 @@ export class WASqliteConnection async close() { this.broadcastChannel?.close(); - await this.sqliteAPI.close(this.dbP); + await this.acquireExecuteLock(async () => { + /** + * Running the close operation inside the same execute mutex prevents errors like: + * ``` + * unable to close due to unfinalized statements or unfinished backups + * ``` + */ + await this.sqliteAPI.close(this.dbP); + }); } async registerOnTableChange(callback: OnTableChangeCallback) { diff --git a/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts b/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts index 487d121aa..fa4528907 100644 --- a/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts +++ b/packages/web/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts @@ -1,17 +1,17 @@ -import { type ILogLevel, DBAdapter } from '@powersync/common'; +import { DBAdapter, type ILogLevel } from '@powersync/common'; import * as Comlink from 'comlink'; import { openWorkerDatabasePort, resolveWorkerDatabasePortFactory } from '../../../worker/db/open-worker-database'; import { AbstractWebSQLOpenFactory } from '../AbstractWebSQLOpenFactory'; import { AsyncDatabaseConnection, OpenAsyncDatabaseConnection } from '../AsyncDatabaseConnection'; import { LockedAsyncDatabaseAdapter } from '../LockedAsyncDatabaseAdapter'; +import { WorkerWrappedAsyncDatabaseConnection } from '../WorkerWrappedAsyncDatabaseConnection'; import { DEFAULT_CACHE_SIZE_KB, ResolvedWebSQLOpenOptions, TemporaryStorageOption, WebSQLOpenFactoryOptions } from '../web-sql-flags'; -import { WorkerWrappedAsyncDatabaseConnection } from '../WorkerWrappedAsyncDatabaseConnection'; -import { WASqliteConnection, WASQLiteVFS } from './WASQLiteConnection'; +import { WASQLiteVFS, WASqliteConnection } from './WASQLiteConnection'; export interface WASQLiteOpenFactoryOptions extends WebSQLOpenFactoryOptions { vfs?: WASQLiteVFS; diff --git a/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts b/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts index 66d3b51ed..fe2bb3ad2 100644 --- a/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts +++ b/packages/web/src/db/sync/SharedWebStreamingSyncImplementation.ts @@ -2,20 +2,19 @@ import { PowerSyncConnectionOptions, PowerSyncCredentials, SubscribedStream, - SyncStatus, SyncStatusOptions } from '@powersync/common'; import * as Comlink from 'comlink'; +import { getNavigatorLocks } from '../../shared/navigator'; import { AbstractSharedSyncClientProvider } from '../../worker/sync/AbstractSharedSyncClientProvider'; import { ManualSharedSyncPayload, SharedSyncClientEvent } from '../../worker/sync/SharedSyncImplementation'; -import { DEFAULT_CACHE_SIZE_KB, resolveWebSQLFlags, TemporaryStorageOption } from '../adapters/web-sql-flags'; +import { WorkerClient } from '../../worker/sync/WorkerClient'; import { WebDBAdapter } from '../adapters/WebDBAdapter'; +import { DEFAULT_CACHE_SIZE_KB, TemporaryStorageOption, resolveWebSQLFlags } from '../adapters/web-sql-flags'; import { WebStreamingSyncImplementation, WebStreamingSyncImplementationOptions } from './WebStreamingSyncImplementation'; -import { WorkerClient } from '../../worker/sync/WorkerClient'; -import { getNavigatorLocks } from '../../shared/navigator'; /** * The shared worker will trigger methods on this side of the message port @@ -146,7 +145,25 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem ).port; } + /** + * Pass along any sync status updates to this listener + */ + this.clientProvider = new SharedSyncClientProvider( + this.webOptions, + (status) => { + this.updateSyncStatus(status); + }, + options.db + ); + this.syncManager = Comlink.wrap(this.messagePort); + /** + * The sync worker will call this client provider when it needs + * to fetch credentials or upload data. + * This performs bi-directional method calling. + */ + Comlink.expose(this.clientProvider, this.messagePort); + this.syncManager.setLogLevel(this.logger.getLevel()); this.triggerCrudUpload = this.syncManager.triggerCrudUpload; @@ -157,10 +174,49 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem * DB worker, but a port to the DB worker can be transferred to the * sync worker. */ + + this.isInitialized = this._init(); + } + + protected async _init() { + /** + * The general flow of initialization is: + * - The client requests a unique navigator lock. + * - Once the lock is acquired, we register the lock with the shared worker. + * - The shared worker can then request the same lock. The client has been closed if the shared worker can acquire the lock. + * - Once the shared worker knows the client's lock, we can guarentee that the shared worker will detect if the client has been closed. + * - This makes the client safe for the shared worker to use. + * - The client is only added to the SharedSyncImplementation once the lock has been registered. + * This ensures we don't ever keep track of dead clients (tabs that closed before the lock was registered). + * - The client side lock is held until the client is disposed. + * - We resolve the top-level promise after the lock has been registered with the shared worker. + * - The client sends the params to the shared worker after locks have been registered. + */ + await new Promise((resolve) => { + // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which + // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker + // to free resources associated with this tab. + // We take hold of this lock as soon-as-possible in order to cater for potentially closed tabs. + getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => { + if (this.abortOnClose.signal.aborted) { + return; + } + // Awaiting here ensures the worker is waiting for the lock + await this.syncManager.addLockBasedCloseSignal(lock!.name); + + // The lock has been registered, we can continue with the initialization + resolve(); + + await new Promise((r) => { + this.abortOnClose.signal.onabort = () => r(); + }); + }); + }); + const { crudUploadThrottleMs, identifier, retryDelayMs } = this.options; const flags = { ...this.webOptions.flags, workers: undefined }; - this.isInitialized = this.syncManager.setParams( + await this.syncManager.setParams( { dbParams: this.dbAdapter.getConfiguration(), streamOptions: { @@ -170,39 +226,8 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem flags: flags } }, - options.subscriptions + this.options.subscriptions ); - - /** - * Pass along any sync status updates to this listener - */ - this.clientProvider = new SharedSyncClientProvider( - this.webOptions, - (status) => { - this.iterateListeners((l) => this.updateSyncStatus(status)); - }, - options.db - ); - - /** - * The sync worker will call this client provider when it needs - * to fetch credentials or upload data. - * This performs bi-directional method calling. - */ - Comlink.expose(this.clientProvider, this.messagePort); - - // Request a random lock until this client is disposed. The name of the lock is sent to the shared worker, which - // will also attempt to acquire it. Since the lock is returned when the tab is closed, this allows the share worker - // to free resources associated with this tab. - getNavigatorLocks().request(`tab-close-signal-${crypto.randomUUID()}`, async (lock) => { - if (!this.abortOnClose.signal.aborted) { - this.syncManager.addLockBasedCloseSignal(lock!.name); - - await new Promise((r) => { - this.abortOnClose.signal.onabort = () => r(); - }); - } - }); } /** @@ -231,8 +256,6 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem async dispose(): Promise { await this.waitForReady(); - await super.dispose(); - await new Promise((resolve) => { // Listen for the close acknowledgment from the worker this.messagePort.addEventListener('message', (event) => { @@ -249,6 +272,9 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem }; this.messagePort.postMessage(closeMessagePayload); }); + + await super.dispose(); + this.abortOnClose.abort(); // Release the proxy @@ -263,12 +289,4 @@ export class SharedWebStreamingSyncImplementation extends WebStreamingSyncImplem updateSubscriptions(subscriptions: SubscribedStream[]): void { this.syncManager.updateSubscriptions(subscriptions); } - - /** - * Used in tests to force a connection states - */ - private async _testUpdateStatus(status: SyncStatus) { - await this.isInitialized; - return this.syncManager._testUpdateAllStatuses(status.toJSON()); - } } diff --git a/packages/web/src/worker/db/WASQLiteDB.worker.ts b/packages/web/src/worker/db/WASQLiteDB.worker.ts index 4f40fdad4..4fd1bd747 100644 --- a/packages/web/src/worker/db/WASQLiteDB.worker.ts +++ b/packages/web/src/worker/db/WASQLiteDB.worker.ts @@ -17,7 +17,6 @@ const logger = createLogger('db-worker'); const DBMap = new Map(); const OPEN_DB_LOCK = 'open-wasqlite-db'; - let nextClientId = 1; const openDBShared = async (options: WorkerDBOpenerOptions): Promise => { diff --git a/packages/web/src/worker/sync/SharedSyncImplementation.ts b/packages/web/src/worker/sync/SharedSyncImplementation.ts index 5c4f5b683..3da979956 100644 --- a/packages/web/src/worker/sync/SharedSyncImplementation.ts +++ b/packages/web/src/worker/sync/SharedSyncImplementation.ts @@ -2,14 +2,14 @@ import { AbortOperation, BaseObserver, ConnectionManager, - createLogger, DBAdapter, PowerSyncBackendConnector, SqliteBucketStorage, SubscribedStream, SyncStatus, - type ILogger, + createLogger, type ILogLevel, + type ILogger, type PowerSyncConnectionOptions, type StreamingSyncImplementation, type StreamingSyncImplementationListener, @@ -25,8 +25,8 @@ import { import { OpenAsyncDatabaseConnection } from '../../db/adapters/AsyncDatabaseConnection'; import { LockedAsyncDatabaseAdapter } from '../../db/adapters/LockedAsyncDatabaseAdapter'; -import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags'; import { WorkerWrappedAsyncDatabaseConnection } from '../../db/adapters/WorkerWrappedAsyncDatabaseConnection'; +import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags'; import { AbstractSharedSyncClientProvider } from './AbstractSharedSyncClientProvider'; import { BroadcastLogger } from './BroadcastLogger'; @@ -76,6 +76,7 @@ export type WrappedSyncPort = { db?: DBAdapter; currentSubscriptions: SubscribedStream[]; closeListeners: (() => void | Promise)[]; + isClosing: boolean; }; /** @@ -106,7 +107,6 @@ export class SharedSyncImplementation extends BaseObserver { - return this.portMutex.runExclusive(async () => { - await this.waitForReady(); - if (!this.dbAdapter) { - await this.openInternalDB(); - } - - const sync = this.generateStreamingImplementation(); - const onDispose = sync.registerListener({ - statusChanged: (status) => { - this.updateAllStatuses(status.toJSON()); - } - }); + await this.waitForReady(); - return { - sync, - onDispose - }; + const sync = this.generateStreamingImplementation(); + const onDispose = sync.registerListener({ + statusChanged: (status) => { + this.updateAllStatuses(status.toJSON()); + } }); + + return { + sync, + onDispose + }; }, logger: this.logger }); @@ -171,6 +169,32 @@ export class SharedSyncImplementation extends BaseObserver { + // Find the last port which is not closing + return await this.portMutex.runExclusive(() => { + for (let i = this.ports.length - 1; i >= 0; i--) { + if (!this.ports[i].isClosing) { + return this.ports[i]; + } + } + return; + }); + } + + /** + * In some very rare cases a specific tab might not respond to requests. + * This returns a random port which is not closing. + */ + protected async getRandomWrappedPort(): Promise { + return await this.portMutex.runExclusive(() => { + const nonClosingPorts = this.ports.filter((p) => !p.isClosing); + return nonClosingPorts[Math.floor(Math.random() * nonClosingPorts.length)]; + }); + } + async waitForStatus(status: SyncStatusOptions): Promise { return this.withSyncImplementation(async (sync) => { return sync.waitForStatus(status); @@ -217,33 +241,60 @@ export class SharedSyncImplementation extends BaseObserver { this.collectActiveSubscriptions(); - if (this.syncParams) { - // Cannot modify already existing sync implementation params - // But we can ask for a DB adapter, if required, at this point. - - if (!this.dbAdapter) { - await this.openInternalDB(); - } - return; - } + }); - // First time setting params - this.syncParams = params; - if (params.streamOptions?.flags?.broadcastLogs) { - this.logger = this.broadCastLogger; - } + if (this.syncParams) { + // Cannot modify already existing sync implementation params + return; + } - self.onerror = (event) => { - // Share any uncaught events on the broadcast logger - this.logger.error('Uncaught exception in PowerSync shared sync worker', event); - }; + // First time setting params + this.syncParams = params; + if (params.streamOptions?.flags?.broadcastLogs) { + this.logger = this.broadCastLogger; + } - if (!this.dbAdapter) { - await this.openInternalDB(); + const lockedAdapter = new LockedAsyncDatabaseAdapter({ + name: params.dbParams.dbFilename, + openConnection: async () => { + // Gets a connection from the clients when a new connection is requested. + const db = await this.openInternalDB(); + db.registerListener({ + closing: () => { + lockedAdapter.reOpenInternalDB(); + } + }); + return db; + }, + logger: this.logger, + reOpenOnConnectionClosed: true + }); + this.distributedDB = lockedAdapter; + await lockedAdapter.init(); + + lockedAdapter.registerListener({ + databaseReOpened: () => { + // We may have missed some table updates while the database was closed. + // We can poke the crud in case we missed any updates. + this.connectionManager.syncStreamImplementation?.triggerCrudUpload(); + + /** + * FIXME or IMPROVE ME + * The Rust client implementation stores sync state on the connection level. + * Reopening the database causes a state machine error which should cause the + * StreamingSyncImplementation to reconnect. It would be nicer if we could trigger + * this reconnect earlier. + * This reconnect is not required for IndexedDB. + */ } - - this.iterateListeners((l) => l.initialized?.()); }); + + self.onerror = (event) => { + // Share any uncaught events on the broadcast logger + this.logger.error('Uncaught exception in PowerSync shared sync worker', event); + }; + + this.iterateListeners((l) => l.initialized?.()); } async dispose() { @@ -276,7 +327,8 @@ export class SharedSyncImplementation extends BaseObserver(port), currentSubscriptions: [], - closeListeners: [] + closeListeners: [], + isClosing: false } satisfies WrappedSyncPort; this.ports.push(portProvider); @@ -295,14 +347,17 @@ export class SharedSyncImplementation extends BaseObserver { + return await this.portMutex.runExclusive(async () => { const index = this.ports.findIndex((p) => p == port); if (index < 0) { this.logger.warn(`Could not remove port ${port} since it is not present in active ports.`); - return {}; + return () => {}; } const trackedPort = this.ports[index]; @@ -321,42 +376,15 @@ export class SharedSyncImplementation extends BaseObserver 0; - return { - shouldReconnect, - trackedPort - }; - }); - - if (!trackedPort) { - // We could not find the port to remove - return () => {}; - } - - for (const closeListener of trackedPort.closeListeners) { - await closeListener(); - } - - if (this.dbAdapter && this.dbAdapter == trackedPort.db) { - // Unconditionally close the connection because the database it's writing to has just been closed. - // The connection has been closed previously, this might throw. We should be able to ignore it. - await this.connectionManager - .disconnect() - .catch((ex) => this.logger.warn('Error while disconnecting. Will attempt to reconnect.', ex)); - - // Clearing the adapter will result in a new one being opened in connect - this.dbAdapter = null; - - if (shouldReconnect) { - await this.connectionManager.connect(CONNECTOR_PLACEHOLDER, this.lastConnectOptions ?? {}); + // Close the worker wrapped database connection, we can't accurately rely on this connection + for (const closeListener of trackedPort.closeListeners) { + await closeListener(); } - } - // Re-index subscriptions, the subscriptions of the removed port would no longer be considered. - this.collectActiveSubscriptions(); + this.collectActiveSubscriptions(); - // Release proxy - return () => trackedPort.clientProvider[Comlink.releaseProxy](); + return () => trackedPort.clientProvider[Comlink.releaseProxy](); + }); } triggerCrudUpload() { @@ -401,11 +429,14 @@ export class SharedSyncImplementation extends BaseObserver { - const lastPort = this.ports[this.ports.length - 1]; + const lastPort = await this.getLastWrappedPort(); + if (!lastPort) { + throw new Error('No client port found to invalidate credentials'); + } try { this.logger.log('calling the last port client provider to invalidate credentials'); lastPort.clientProvider.invalidateCredentials(); @@ -414,7 +445,10 @@ export class SharedSyncImplementation extends BaseObserver { - const lastPort = this.ports[this.ports.length - 1]; + const lastPort = await this.getLastWrappedPort(); + if (!lastPort) { + throw new Error('No client port found to fetch credentials'); + } return new Promise(async (resolve, reject) => { const abortController = new AbortController(); this.fetchCredentialsController = { @@ -437,7 +471,10 @@ export class SharedSyncImplementation extends BaseObserver { - const lastPort = this.ports[this.ports.length - 1]; + const lastPort = await this.getLastWrappedPort(); + if (!lastPort) { + throw new Error('No client port found to upload crud'); + } return new Promise(async (resolve, reject) => { const abortController = new AbortController(); @@ -464,39 +501,91 @@ export class SharedSyncImplementation extends BaseObserver { + abortController.abort(); + }, 10_000); + + /** + * Handle cases where the client might close while opening a connection. + */ + const abortController = new AbortController(); + const closeListener = () => { + abortController.abort(); + }; + + const removeCloseListener = () => { + const index = client.closeListeners.indexOf(closeListener); + if (index >= 0) { + client.closeListeners.splice(index, 1); + } + }; + + client.closeListeners.push(closeListener); + + const workerPort = await withAbort({ + action: () => client.clientProvider.getDBWorkerPort(), + signal: abortController.signal, + cleanupOnAbort: (port) => { + port.close(); + } + }).catch((ex) => { + removeCloseListener(); + throw ex; + }); + const remote = Comlink.wrap(workerPort); const identifier = this.syncParams!.dbParams.dbFilename; - const db = await remote(this.syncParams!.dbParams); - const locked = new LockedAsyncDatabaseAdapter({ - name: identifier, - openConnection: async () => { - const wrapped = new WorkerWrappedAsyncDatabaseConnection({ - remote, - baseConnection: db, - identifier, - // It's possible for this worker to outlive the client hosting the database for us. We need to be prepared for - // that and ensure pending requests are aborted when the tab is closed. - remoteCanCloseUnexpectedly: true - }); - lastClient.closeListeners.push(async () => { - this.logger.info('Aborting open connection because associated tab closed.'); - await wrapped.close().catch((ex) => this.logger.warn('error closing database connection', ex)); - wrapped.markRemoteClosed(); - }); - return wrapped; - }, - logger: this.logger + /** + * The open could fail if the tab is closed while we're busy opening the database. + * This operation is typically executed inside an exclusive portMutex lock. + * We typically execute the closeListeners using the portMutex in a different context. + * We can't rely on the closeListeners to abort the operation if the tab is closed. + */ + const db = await withAbort({ + action: () => remote(this.syncParams!.dbParams), + signal: abortController.signal, + cleanupOnAbort: (db) => { + db.close(); + } + }).finally(() => { + // We can remove the close listener here since we no longer need it past this point. + removeCloseListener(); + }); + + clearTimeout(timeout); + + const wrapped = new WorkerWrappedAsyncDatabaseConnection({ + remote, + baseConnection: db, + identifier, + // It's possible for this worker to outlive the client hosting the database for us. We need to be prepared for + // that and ensure pending requests are aborted when the tab is closed. + remoteCanCloseUnexpectedly: true }); - await locked.init(); - this.dbAdapter = lastClient.db = locked; + client.closeListeners.push(async () => { + this.logger.info('Aborting open connection because associated tab closed.'); + /** + * Don't await this close operation. It might never resolve if the tab is closed. + * We mark the remote as closed first, this will reject any pending requests. + * We then call close. The close operation is configured to fire-and-forget, the main promise will reject immediately. + */ + wrapped.markRemoteClosed(); + wrapped.close().catch((ex) => this.logger.warn('error closing database connection', ex)); + }); + + return wrapped; } /** @@ -507,17 +596,43 @@ export class SharedSyncImplementation extends BaseObserver p.clientProvider.statusChanged(status)); } +} - /** - * A function only used for unit tests which updates the internal - * sync stream client and all tab client's sync status - */ - async _testUpdateAllStatuses(status: SyncStatusOptions) { - if (!this.connectionManager.syncStreamImplementation) { - throw new Error('Cannot update status without a sync stream implementation'); +/** + * Runs the action with an abort controller. + */ +function withAbort(options: { + action: () => Promise; + signal: AbortSignal; + cleanupOnAbort?: (result: T) => void; +}): Promise { + const { action, signal, cleanupOnAbort } = options; + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new AbortOperation('Operation aborted by abort controller')); + return; } - // Only assigning, don't call listeners for this test - this.connectionManager.syncStreamImplementation!.syncStatus = new SyncStatus(status); - this.updateAllStatuses(status); - } + + function handleAbort() { + signal.removeEventListener('abort', handleAbort); + reject(new AbortOperation('Operation aborted by abort controller')); + } + + signal.addEventListener('abort', handleAbort, { once: true }); + + function completePromise(action: () => void) { + signal.removeEventListener('abort', handleAbort); + action(); + } + + action() + .then((data) => { + // We already rejected due to the abort, allow for cleanup + if (signal.aborted) { + return completePromise(() => cleanupOnAbort?.(data)); + } + completePromise(() => resolve(data)); + }) + .catch((e) => completePromise(() => reject(e))); + }); } diff --git a/packages/web/src/worker/sync/SharedSyncImplementation.worker.ts b/packages/web/src/worker/sync/SharedSyncImplementation.worker.ts index 157d7c1cb..8693575b6 100644 --- a/packages/web/src/worker/sync/SharedSyncImplementation.worker.ts +++ b/packages/web/src/worker/sync/SharedSyncImplementation.worker.ts @@ -10,5 +10,5 @@ const sharedSyncImplementation = new SharedSyncImplementation(); _self.onconnect = async function (event: MessageEvent) { const port = event.ports[0]; - await new WorkerClient(sharedSyncImplementation, port).initialize(); + new WorkerClient(sharedSyncImplementation, port); }; diff --git a/packages/web/src/worker/sync/WorkerClient.ts b/packages/web/src/worker/sync/WorkerClient.ts index 1dfffe9b2..519d42119 100644 --- a/packages/web/src/worker/sync/WorkerClient.ts +++ b/packages/web/src/worker/sync/WorkerClient.ts @@ -1,4 +1,6 @@ +import { ILogLevel, PowerSyncConnectionOptions, SubscribedStream } from '@powersync/common'; import * as Comlink from 'comlink'; +import { getNavigatorLocks } from '../../shared/navigator'; import { ManualSharedSyncPayload, SharedSyncClientEvent, @@ -6,8 +8,6 @@ import { SharedSyncInitOptions, WrappedSyncPort } from './SharedSyncImplementation'; -import { ILogLevel, PowerSyncConnectionOptions, SubscribedStream, SyncStatusOptions } from '@powersync/common'; -import { getNavigatorLocks } from '../../shared/navigator'; /** * A client to the shared sync worker. @@ -17,13 +17,13 @@ import { getNavigatorLocks } from '../../shared/navigator'; */ export class WorkerClient { private resolvedPort: WrappedSyncPort | null = null; + protected resolvedPortPromise: Promise | null = null; constructor( private readonly sync: SharedSyncImplementation, private readonly port: MessagePort - ) {} - - async initialize() { + ) { + Comlink.expose(this, this.port); /** * Adds an extra listener which can remove this port * from the list of monitored ports. @@ -34,9 +34,6 @@ export class WorkerClient { await this.removePort(); } }); - - this.resolvedPort = await this.sync.addPort(this.port); - Comlink.expose(this, this.port); } private async removePort() { @@ -59,7 +56,10 @@ export class WorkerClient { * When the client tab is closed, its lock will be returned. So when the shared worker attempts to acquire the lock, * it can consider the connection to be closed. */ - addLockBasedCloseSignal(name: string) { + async addLockBasedCloseSignal(name: string) { + // Only add the port once the lock has been obtained on the client. + this.resolvedPort = await this.sync.addPort(this.port); + // Don't await this lock request getNavigatorLocks().request(name, async () => { await this.removePort(); }); @@ -99,8 +99,4 @@ export class WorkerClient { disconnect() { return this.sync.disconnect(); } - - async _testUpdateAllStatuses(status: SyncStatusOptions) { - return this.sync._testUpdateAllStatuses(status); - } } diff --git a/packages/web/tests/error_serialization.test.ts b/packages/web/tests/error_serialization.test.ts new file mode 100644 index 000000000..df192378c --- /dev/null +++ b/packages/web/tests/error_serialization.test.ts @@ -0,0 +1,43 @@ +import { SyncStreamConnectionMethod } from '@powersync/common'; +import { describe, expect } from 'vitest'; +import { sharedMockSyncServiceTest } from './utils/mockSyncServiceTest'; + +/** + * Test to verify that Error instances are properly serialized when passed through MessagePorts. + * When errors occur in the shared worker and are reported via statusChanged, they should + * be properly serialized and deserialized to appear in the sync status. + */ +describe('Error Serialization through MessagePorts', { sequential: true }, () => { + sharedMockSyncServiceTest( + 'should serialize and deserialize Error in sync status when connection fails', + { timeout: 10_000 }, + async ({ context: { database, mockService } }) => { + await mockService.setAutomaticResponse({ + status: 401, + headers: { 'Content-Type': 'application/json' }, + bodyLines: ['Unauthorized'] + }); + + // Start connection attempt + await database.connect( + { + fetchCredentials: async () => { + return { + endpoint: 'http://localhost:3000', + token: 'test-token' + }; + }, + uploadData: async () => {} + }, + { + connectionMethod: SyncStreamConnectionMethod.HTTP + } + ); + + expect(database.currentStatus.dataFlowStatus?.downloadError).toBeDefined(); + expect(database.currentStatus.dataFlowStatus?.downloadError?.name).toBe('Error'); + expect(database.currentStatus.dataFlowStatus?.downloadError?.message).toBe('HTTP : "Unauthorized"\n'); + expect(database.currentStatus.dataFlowStatus?.downloadError?.stack).toBeDefined(); + } + ); +}); diff --git a/packages/web/tests/mockSyncServiceExample.test.ts b/packages/web/tests/mockSyncServiceExample.test.ts new file mode 100644 index 000000000..e7165e944 --- /dev/null +++ b/packages/web/tests/mockSyncServiceExample.test.ts @@ -0,0 +1,88 @@ +/** + * Example test demonstrating how to use the mock sync service for shared worker environments. + * + * This example shows how to: + * 1. Use the sharedMockSyncServiceTest utility to set up the test environment + * 2. Use the mock service to get pending requests and create responses + * 3. Send data via sync stream and query it in the database + * + * Note: This is an example file - rename to .test.ts to use it in actual tests. + */ + +import { StreamingSyncCheckpoint } from '@powersync/common'; +import { describe, expect, vi } from 'vitest'; +import { sharedMockSyncServiceTest } from './utils/mockSyncServiceTest'; + +describe('Mock Sync Service Example', { timeout: 100000 }, () => { + sharedMockSyncServiceTest( + 'should allow mocking sync responses in shared worker', + { timeout: 100000 }, + async ({ context: { database, connect, mockService } }) => { + // Call connect to start the sync worker and get the sync service + const { syncRequestId } = await connect(); + + // Push a checkpoint with buckets (following node test pattern) + const checkpoint: StreamingSyncCheckpoint = { + checkpoint: { + last_op_id: '1', + buckets: [ + { + bucket: 'a', + count: 1, + checksum: 0, + priority: 3 + } + ], + write_checkpoint: undefined + } + }; + + await mockService.pushBodyLine(syncRequestId, checkpoint); + + // The connect call should resolve by now + await mockService.pushBodyLine(syncRequestId, { + data: { + bucket: 'a', + data: [ + { + checksum: 0, + op_id: '1', + op: 'PUT', + object_id: '1', + object_type: 'lists', + data: '{"name": "from server"}' + } + ] + } + }); + + // Push checkpoint_complete to finish the sync + await mockService.pushBodyLine(syncRequestId, { + checkpoint_complete: { + last_op_id: '1' + } + }); + + // Complete the response + await mockService.completeResponse(syncRequestId); + + // Wait for sync to complete and verify the data was saved + await vi.waitFor(async () => { + const rows = await database.getAll('SELECT * FROM lists WHERE id = ?', ['1']); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: '1', + name: 'from server' + }); + }); + + // Verify the data by querying the database + const allRows = await database.getAll('SELECT * FROM lists'); + expect(allRows).toHaveLength(1); + expect(allRows[0]).toMatchObject({ + id: '1', + name: 'from server' + }); + } + ); +}); diff --git a/packages/web/tests/mocks/MockWebRemote.ts b/packages/web/tests/mocks/MockWebRemote.ts new file mode 100644 index 000000000..5613a3f98 --- /dev/null +++ b/packages/web/tests/mocks/MockWebRemote.ts @@ -0,0 +1,128 @@ +import { + AbstractRemote, + AbstractRemoteOptions, + BSONImplementation, + DataStream, + DEFAULT_REMOTE_LOGGER, + FetchImplementation, + FetchImplementationProvider, + ILogger, + RemoteConnector, + SocketSyncStreamOptions +} from '@powersync/common'; +import { serialize, type BSON } from 'bson'; +import { MockSyncService, setupMockServiceMessageHandler } from '../utils/MockSyncServiceWorker'; + +/** + * Mock fetch provider that intercepts all requests and routes them to the mock sync service. + * Used for testing in shared worker environments with enableMultipleTabs: true. + * + * When running in a shared worker context, this will: + * 1. Intercept all requests and register them as pending requests + * 2. Wait for a client to create a response before returning + * 3. Set up message handler for the mock service when onconnect is called + */ +class MockSyncServiceFetchProvider extends FetchImplementationProvider { + getFetch(): FetchImplementation { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init); + + const mockService = MockSyncService.GLOBAL_INSTANCE; + + // Read the request body (if any) + let body: any = null; + try { + if (request.body) { + const clonedRequest = request.clone(); + body = await clonedRequest.json().catch(() => { + // If JSON parsing fails, try text + return clonedRequest.text().catch(() => null); + }); + } + } catch (e) { + // Body might not be readable, that's okay + } + + // Extract headers from the request + const headers: Record = {}; + request.headers.forEach((value, key) => { + headers[key] = value; + }); + + // Register as a pending request and wait for client to create response + return await mockService.registerPendingRequest(request.url, request.method, headers, body, request.signal); + }; + } +} + +/** + * Check if we're running in a shared worker context + */ +function isSharedWorkerContext(): boolean { + const isSharedWorker = + typeof SharedWorkerGlobalScope !== 'undefined' && + typeof self !== 'undefined' && + (self as any).constructor?.name === 'SharedWorkerGlobalScope'; + return isSharedWorker; +} + +if (isSharedWorkerContext()) { + const _self: SharedWorkerGlobalScope = self as any; + console.log('MockWebRemote: setting up connect listener'); + + /** + * This listener should be called in tandem with the shared sync worker's listener. + */ + _self.addEventListener('connect', async function (event: MessageEvent) { + console.log('MockWebRemote: connect listener called'); + const port = event.ports[0]; + + // Set up message handler for the mock service on this port + // Tests can create a separate SharedWorker connection to access this + setupMockServiceMessageHandler(port); + }); +} + +export class WebRemote extends AbstractRemote { + private _bson: BSONImplementation | undefined; + + constructor( + protected connector: RemoteConnector, + protected logger: ILogger = DEFAULT_REMOTE_LOGGER, + options?: Partial + ) { + // Use mock service fetch provider if we're in a shared worker context + const fetchProvider = new MockSyncServiceFetchProvider(); + + super(connector, logger, { + ...(options ?? {}), + fetchImplementation: options?.fetchImplementation ?? fetchProvider + }); + } + + getUserAgent(): string { + return 'powersync-web-mock'; + } + + async getBSON(): Promise { + if (this._bson) { + return this._bson; + } + const { BSON } = await import('bson'); + this._bson = BSON; + return this._bson; + } + + /** + * Override socketStreamRaw to use HTTP method (postStreamRaw) instead. + * This allows us to use the same mocks for both socket and HTTP streaming. + */ + async socketStreamRaw( + options: SocketSyncStreamOptions, + map: (buffer: Uint8Array) => T, + bson?: typeof BSON + ): Promise> { + // postStreamRaw decodes to strings, so convert back to Uint8Array for the map function + return await this.postStreamRaw(options, (line: string) => map(serialize(JSON.parse(line)))); + } +} diff --git a/packages/web/tests/multiple_instances.test.ts b/packages/web/tests/multiple_instances.test.ts index 76fadaf26..a8a239fc4 100644 --- a/packages/web/tests/multiple_instances.test.ts +++ b/packages/web/tests/multiple_instances.test.ts @@ -1,24 +1,11 @@ -import { - AbstractPowerSyncDatabase, - createBaseLogger, - createLogger, - DEFAULT_CRUD_UPLOAD_THROTTLE_MS, - SqliteBucketStorage, - SyncStatus -} from '@powersync/common'; -import { - OpenAsyncDatabaseConnection, - SharedWebStreamingSyncImplementation, - SharedWebStreamingSyncImplementationOptions, - WASqliteConnection, - WebRemote -} from '@powersync/web'; +import { AbstractPowerSyncDatabase, createBaseLogger, createLogger } from '@powersync/common'; +import { OpenAsyncDatabaseConnection, WASqliteConnection } from '@powersync/web'; import * as Comlink from 'comlink'; import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { LockedAsyncDatabaseAdapter } from '../src/db/adapters/LockedAsyncDatabaseAdapter'; import { WebDBAdapter } from '../src/db/adapters/WebDBAdapter'; import { WorkerWrappedAsyncDatabaseConnection } from '../src/db/adapters/WorkerWrappedAsyncDatabaseConnection'; -import { TestConnector } from './utils/MockStreamOpenFactory'; +import { createTestConnector, sharedMockSyncServiceTest } from './utils/mockSyncServiceTest'; import { generateTestDb, testSchema } from './utils/testDb'; const DB_FILENAME = 'test-multiple-instances.db'; @@ -50,45 +37,57 @@ describe('Multiple Instances', { sequential: true }, () => { expect(assets.length).equals(1); }); - it('should broadcast logs from shared sync worker', { timeout: 20000 }, async () => { - const logger = createLogger('test-logger'); - const spiedErrorLogger = vi.spyOn(logger, 'error'); - const spiedDebugLogger = vi.spyOn(logger, 'debug'); - - const powersync = generateTestDb({ - logger, - database: { - dbFilename: 'broadcast-logger-test.sqlite' - }, - schema: testSchema - }); + sharedMockSyncServiceTest( + 'should broadcast logs from shared sync worker', + { timeout: 10_000 }, + async ({ context: { openDatabase, mockService } }) => { + const logger = createLogger('test-logger'); + const spiedErrorLogger = vi.spyOn(logger, 'error'); + const spiedDebugLogger = vi.spyOn(logger, 'debug'); + + // Open an additional database which we can spy on the logs. + const powersync = openDatabase({ + logger + }); - powersync.connect({ - fetchCredentials: async () => { - return { - endpoint: 'http://localhost/does-not-exist', - token: 'none' - }; - }, - uploadData: async (db) => {} - }); + powersync.connect({ + fetchCredentials: async () => { + return { + endpoint: 'http://localhost/does-not-exist', + token: 'none' + }; + }, + uploadData: async (db) => {} + }); - // Should log that a connection attempt has been made - const message = 'Streaming sync iteration started'; - await vi.waitFor( - () => - expect( - spiedDebugLogger.mock.calls - .flat(1) - .find((argument) => typeof argument == 'string' && argument.includes(message)) - ).exist, - { timeout: 2000 } - ); - - // The connection should fail with an error - await vi.waitFor(() => expect(spiedErrorLogger.mock.calls.length).gt(0), { timeout: 2000 }); - // This test seems to take quite long while waiting for this disconnect call - }); + await vi.waitFor( + async () => { + const requests = await mockService.getPendingRequests(); + expect(requests.length).toBeGreaterThan(0); + const pendingRequestId = requests[0].id; + // Generate an error + await mockService.createResponse(pendingRequestId, 401, { 'Content-Type': 'application/json' }); + await mockService.completeResponse(pendingRequestId); + }, + { timeout: 3_000 } + ); + + // Should log that a connection attempt has been made + const message = 'Streaming sync iteration started'; + await vi.waitFor( + () => + expect( + spiedDebugLogger.mock.calls + .flat(1) + .find((argument) => typeof argument == 'string' && argument.includes(message)) + ).exist, + { timeout: 2000 } + ); + + // The connection should fail with an error + await vi.waitFor(() => expect(spiedErrorLogger.mock.calls.length).gt(0), { timeout: 2000 }); + } + ); it('should maintain DB connections if instances call close', async () => { /** @@ -104,7 +103,7 @@ describe('Multiple Instances', { sequential: true }, () => { await createAsset(powersync2); }); - it('should handled interrupted transactions', { timeout: Infinity }, async () => { + it('should handled interrupted transactions', async () => { //Create a shared PowerSync database. We'll just use this for internally managing connections. const powersync = openDatabase(); await powersync.init(); @@ -223,170 +222,82 @@ describe('Multiple Instances', { sequential: true }, () => { await watchedPromise; }); - it('should share sync updates', async () => { - // Generate the first streaming sync implementation - const connector1 = new TestConnector(); - const db = openDatabase(); - await db.init(); - - // They need to use the same identifier to use the same shared worker. - const identifier = 'streaming-sync-shared'; - const syncOptions1: SharedWebStreamingSyncImplementationOptions = { - adapter: new SqliteBucketStorage(db.database), - remote: new WebRemote(connector1), - uploadCrud: async () => { - await connector1.uploadData(db); - }, - identifier, - crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS, - retryDelayMs: 90_000, // Large delay to allow for testing - db: db.database as WebDBAdapter, - subscriptions: [] - }; - - const stream1 = new SharedWebStreamingSyncImplementation(syncOptions1); - await stream1.connect(); - // Generate the second streaming sync implementation - const connector2 = new TestConnector(); - const syncOptions2: SharedWebStreamingSyncImplementationOptions = { - adapter: new SqliteBucketStorage(db.database), - remote: new WebRemote(connector1), - uploadCrud: async () => { - await connector2.uploadData(db); - }, - identifier, - crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS, - retryDelayMs: 90_000, // Large delay to allow for testing - db: db.database as WebDBAdapter, - subscriptions: [] - }; - - const stream2 = new SharedWebStreamingSyncImplementation(syncOptions2); - - const stream2UpdatedPromise = new Promise((resolve, reject) => { - const l = stream2.registerListener({ - statusChanged: (status) => { - if (status.connected) { - resolve(); - l(); - } - } + sharedMockSyncServiceTest( + 'should share sync updates', + { timeout: 10_000 }, + async ({ context: { database, connect, openDatabase } }) => { + const secondDatabase = openDatabase(); + + expect(database.currentStatus.connected).false; + expect(secondDatabase.currentStatus.connected).false; + // connect the second database in order for it to have access to the sync service. + secondDatabase.connect(createTestConnector()); + // Timing of this can be tricky due to the need for responding to a pending request. + await vi.waitFor(() => expect(secondDatabase.currentStatus.connecting).true, { timeout: 5_000 }); + // connect the first database - this will actually connect to the sync service. + await connect(); + + expect(database.currentStatus.connected).true; + + await vi.waitFor(() => expect(secondDatabase.currentStatus.connected).true, { timeout: 5_000 }); + } + ); + + sharedMockSyncServiceTest( + 'should trigger uploads from last connected clients', + async ({ context: { database, openDatabase, connect, connector, mockService } }) => { + const secondDatabase = openDatabase(); + + expect(database.currentStatus.connected).false; + expect(secondDatabase.currentStatus.connected).false; + + // Don't actually upload data + connector.uploadData.mockImplementation(async (db) => { + console.log('uploading from first client'); }); - }); - - // hack to set the status to a new one for tests - (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); - - await stream2UpdatedPromise; - expect(stream2.isConnected).true; - - await stream1.dispose(); - await stream2.dispose(); - }); - - it('should trigger uploads from last connected clients', async () => { - // Generate the first streaming sync implementation - const connector1 = new TestConnector(); - const spy1 = vi.spyOn(connector1, 'uploadData'); - const db = openDatabase(); - await db.init(); - // They need to use the same identifier to use the same shared worker. - const identifier = db.database.name; + // Create something with CRUD in it. + await database.execute('INSERT into lists (id, name) VALUES (uuid(), ?)', ['steven']); - // Resolves once the first connector has been called to upload data - let triggerUpload1: () => void; - const upload1TriggeredPromise = new Promise((resolve) => { - triggerUpload1 = resolve; - }); - - const sharedSyncOptions = { - adapter: new SqliteBucketStorage(db.database), - remote: new WebRemote(connector1), - db: db.database as WebDBAdapter, - identifier, - // The large delay here allows us to test between connection retries - retryDelayMs: 90_000, - crudUploadThrottleMs: DEFAULT_CRUD_UPLOAD_THROTTLE_MS, - subscriptions: [], - flags: { - broadcastLogs: true - } - }; - - // Create the first streaming client - const stream1 = new SharedWebStreamingSyncImplementation({ - ...sharedSyncOptions, - uploadCrud: async () => { - triggerUpload1(); - connector1.uploadData(db); - } - }); + // connect from the first database + await connect(); - // Generate the second streaming sync implementation - const connector2 = new TestConnector(); - // The second connector will be called first to upload, we don't want it to actually upload - // This will cause the sync uploads to be delayed as the CRUD queue did not change - const spy2 = vi.spyOn(connector2, 'uploadData').mockImplementation(async () => {}); + await vi.waitFor(() => expect(database.currentStatus.connected).true); - let triggerUpload2: () => void; - const upload2TriggeredPromise = new Promise((resolve) => { - triggerUpload2 = resolve; - }); + // It should initially try and upload from the first client + await vi.waitFor(() => expect(connector.uploadData).toHaveBeenCalledOnce(), { timeout: 2000 }); - const stream2 = new SharedWebStreamingSyncImplementation({ - ...sharedSyncOptions, - uploadCrud: async () => { - triggerUpload2(); - connector2.uploadData(db); - } - }); - - // Waits for the stream to be marked as connected - const stream2UpdatedPromise = new Promise((resolve, reject) => { - const l = stream2.registerListener({ - statusChanged: (status) => { - if (status.connected) { - resolve(); - l(); - } - } + const secondConnector = createTestConnector(); + // Don't actually upload data + secondConnector.uploadData.mockImplementation(async (db) => { + console.log('uploading from second client'); }); - }); - - // hack to set the status to connected for tests - await stream1.connect(); - // Hack, set the status to connected in order to trigger the upload - await (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); - - // The status in the second stream client should be updated - await stream2UpdatedPromise; - - expect(stream2.isConnected).true; - - // Create something with CRUD in it. - await db.execute('INSERT into customers (id, name, email) VALUES (uuid(), ?, ?)', [ - 'steven', - 'steven@journeyapps.com' - ]); - stream1.triggerCrudUpload(); - // The second connector should be called to upload - await upload2TriggeredPromise; - - // It should call the latest connected client - expect(spy2).toHaveBeenCalledOnce(); + // Connect the second database and wait for a pending request to appear + const secondConnectPromise = secondDatabase.connect(secondConnector); + let _pendingRequestId: string; + await vi.waitFor(async () => { + const requests = await mockService.getPendingRequests(); + expect(requests.length).toBeGreaterThan(0); + _pendingRequestId = requests[0].id; + }); + const pendingRequestId = _pendingRequestId!; + await mockService.createResponse(pendingRequestId, 200, { 'Content-Type': 'application/json' }); + await mockService.pushBodyLine(pendingRequestId, { + token_expires_in: 10000000 + }); + await secondConnectPromise; - // Close the second client, leaving only the first one - await stream2.dispose(); + // It should now upload from the second client + await vi.waitFor(() => expect(secondConnector.uploadData).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 5000)); + // Now disconnect and close the second client + await secondDatabase.close(); - // Hack, set the status to connected in order to trigger the upload - await (stream1 as any)['_testUpdateStatus'](new SyncStatus({ connected: true })); - stream1.triggerCrudUpload(); - // It should now upload from the first client - await upload1TriggeredPromise; + expect(database.currentStatus.connected).true; - expect(spy1).toHaveBeenCalledOnce(); - await stream1.dispose(); - }); + // It should now upload from the first client + await vi.waitFor(() => expect(connector.uploadData.mock.calls.length).greaterThanOrEqual(2), { timeout: 3000 }); + } + ); }); diff --git a/packages/web/tests/multiple_tabs_iframe.test.ts b/packages/web/tests/multiple_tabs_iframe.test.ts new file mode 100644 index 000000000..038425932 --- /dev/null +++ b/packages/web/tests/multiple_tabs_iframe.test.ts @@ -0,0 +1,407 @@ +import { WASQLiteVFS } from '@powersync/web'; +import { v4 as uuid } from 'uuid'; +import { describe, expect, it, onTestFinished } from 'vitest'; + +/** + * Creates an iframe with a PowerSync client that connects using the same database. + * The iframe uses dynamic import to load PowerSync modules. + * + * Note: This approach works in Vitest browser mode where modules are available + * via the Vite dev server. The iframe needs to access modules from the same origin. + */ +interface IframeClient { + iframe: HTMLIFrameElement; + cleanup: () => Promise; + executeQuery: (query: string, parameters?: unknown[]) => Promise; + getCredentialsFetchCount: () => Promise; +} + +interface IframeClientResult { + iframe: HTMLIFrameElement; + cleanup: () => Promise; + ready: Promise; +} + +// Run tests for both IndexedDB and OPFS +createMultipleTabsTest(); // IndexedDB (default) +createMultipleTabsTest(WASQLiteVFS.OPFSCoopSyncVFS); + +function createIframeWithPowerSyncClient( + dbFilename: string, + identifier: string, + vfs?: WASQLiteVFS, + waitForConnection?: boolean, + configureMockResponses?: boolean +): IframeClientResult { + const iframe = document.createElement('iframe'); + // Make iframe visible for debugging + iframe.style.display = 'block'; + iframe.style.width = '300px'; + iframe.style.height = '150px'; + iframe.style.border = '2px solid #007bff'; + iframe.style.margin = '10px'; + iframe.style.borderRadius = '4px'; + iframe.title = `PowerSync Client: ${identifier}`; + document.body.appendChild(iframe); + + // Get the base URL for module imports + // In Vitest browser mode, we need to construct a path relative to where the test file is served + // Use import.meta.url to get the current test file's location + const testFileUrl = new URL(import.meta.url); + const testFileDir = testFileUrl.pathname.substring(0, testFileUrl.pathname.lastIndexOf('/')); + // Construct the absolute path to the initializer module relative to the test file + const modulePath = `${testFileUrl.origin}${testFileDir}/utils/iframeInitializer.ts`; + + // Create HTML content with module script that imports and executes the setup function + // Vite will serve the module file, allowing proper module resolution + const htmlContent = ` + + + + PowerSync Client ${identifier} + + + +
+
ID:${identifier}
+
DB:${dbFilename}
+
VFS:${vfs || 'IndexedDB (default)'}
+
+ + +`; + + const blob = new Blob([htmlContent], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + iframe.src = url; + + let requestIdCounter = 0; + const pendingRequests = new Map< + string, + { + resolve: (value: any) => void; + reject: (error: Error) => void; + } + >(); + + let messageHandler: ((event: MessageEvent) => void) | null = null; + let isCleanedUp = false; + + // Create cleanup function that can be called immediately + const cleanup = async (): Promise => { + if (isCleanedUp) { + return; + } + isCleanedUp = true; + + // Remove message handler if it was added + if (messageHandler) { + window.removeEventListener('message', messageHandler); + messageHandler = null; + } + + // Simulate abrupt tab closure - just remove the iframe without calling + // disconnect/close on the PowerSync client. This tests dead tab detection. + URL.revokeObjectURL(url); + if (iframe.parentNode) { + iframe.remove(); + } + }; + + // Create promise that resolves when powersync-ready is received + const ready = new Promise((resolve, reject) => { + messageHandler = async (event: MessageEvent) => { + if (isCleanedUp) { + return; + } + + const data = event.data; + + if (data?.type === 'powersync-ready' && data.identifier === identifier) { + // Don't remove the message handler - we need it to receive query results! + resolve({ + iframe, + cleanup, + executeQuery: (query: string, parameters?: unknown[]): Promise => { + return new Promise((resolveQuery, rejectQuery) => { + if (isCleanedUp) { + rejectQuery(new Error('Iframe has been cleaned up')); + return; + } + + const requestId = `query-${identifier}-${++requestIdCounter}`; + pendingRequests.set(requestId, { + resolve: resolveQuery, + reject: rejectQuery + }); + + const iframeWindow = iframe.contentWindow; + if (!iframeWindow) { + pendingRequests.delete(requestId); + rejectQuery(new Error('Iframe window not available')); + return; + } + + iframeWindow.postMessage( + { + type: 'execute-query', + requestId, + query, + parameters + }, + '*' + ); + + // Cleanup after timeout to prevent memory leaks + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + rejectQuery(new Error('Query timeout')); + } + }, 30000); + }); + }, + getCredentialsFetchCount: (): Promise => { + return new Promise((resolveCount, rejectCount) => { + if (isCleanedUp) { + rejectCount(new Error('Iframe has been cleaned up')); + return; + } + + const requestId = `credentials-count-${identifier}-${++requestIdCounter}`; + pendingRequests.set(requestId, { + resolve: resolveCount, + reject: rejectCount + }); + + const iframeWindow = iframe.contentWindow; + if (!iframeWindow) { + pendingRequests.delete(requestId); + rejectCount(new Error('Iframe window not available')); + return; + } + + iframeWindow.postMessage( + { + type: 'get-credentials-count', + requestId + }, + '*' + ); + + // Cleanup after timeout to prevent memory leaks + setTimeout(() => { + if (pendingRequests.has(requestId)) { + pendingRequests.delete(requestId); + rejectCount(new Error('Credentials count request timeout')); + } + }, 10000); + }); + } + }); + } else if (data?.type === 'powersync-error' && data.identifier === identifier) { + if (messageHandler) { + window.removeEventListener('message', messageHandler); + messageHandler = null; + } + URL.revokeObjectURL(url); + if (iframe.parentNode) { + iframe.remove(); + } + reject(new Error(`PowerSync error in iframe: ${data.error}`)); + } else if (data?.type === 'query-result' && data.identifier === identifier) { + const pending = pendingRequests.get(data.requestId); + if (pending) { + pendingRequests.delete(data.requestId); + if (data.success) { + pending.resolve(data.result); + } else { + pending.reject(new Error(data.error || 'Query failed')); + } + } + } else if (data?.type === 'credentials-count-result' && data.identifier === identifier) { + const pending = pendingRequests.get(data.requestId); + if (pending) { + pendingRequests.delete(data.requestId); + if (data.success) { + pending.resolve(data.count); + } else { + pending.reject(new Error(data.error || 'Credentials count request failed')); + } + } + } + }; + window.addEventListener('message', messageHandler); + }); + + return { + iframe, + cleanup, + ready + }; +} + +/** + * Test suite for simulating multiple browser tabs with PowerSync clients. + * + * Purpose: + * These tests simulate the behavior of closing and reopening multiple browser tabs + * that share a PowerSync database connection via a SharedWorker. This is critical + * for testing PowerSync's dead tab detection and resource cleanup mechanisms. + * + * Iframe vs Real Tab Behavior: + * Closing an iframe by removing it from the DOM is similar to closing a real browser tab + * for PowerSync's purposes because: + * 1. Navigator Locks API: PowerSync uses Navigator Locks to detect tab closure. When an + * iframe is removed, its execution context is destroyed and any held locks are automatically + * released, just like when a real tab closes. This is the primary mechanism PowerSync uses + * for dead tab detection (see SharedWebStreamingSyncImplementation.ts). + * 2. MessagePort Closure: When an iframe is removed, any MessagePorts used for communication + * with the SharedWorker are closed, triggering cleanup in the worker. + * 3. Window Unload: The iframe's window context is destroyed, which would trigger unload + * event listeners if registered (PowerSyncDatabase registers an 'unload' listener when + * enableMultiTabs is true). + * + * Test Scenarios: + * - Opening 100 tabs simultaneously + * - Waiting 1 second for all tabs to initialize + * - Simultaneously closing all tabs except the middle (50th) tab + * - Verifying that the remaining tab is still functional and the shared database + * connection is properly maintained after closing 99 tabs + * + * This test suite runs for both IndexedDB and OPFS VFS backends to ensure dead tab + * detection works correctly across different storage mechanisms. + */ +function createMultipleTabsTest(vfs?: WASQLiteVFS) { + const vfsName = vfs || 'IndexedDB'; + describe(`Multiple Tabs with Iframes (${vfsName})`, { sequential: true, timeout: 30_000 }, () => { + const dbFilename = `test-multi-tab-${uuid()}.db`; + + // Number of tabs to create + const NUM_TABS = 100; + // Index of the middle tab to keep (0-indexed, so 49 is the 50th tab) + const MIDDLE_TAB_INDEX = 49; + + it('should handle opening and closing many tabs quickly', async () => { + // Step 0: Create an iframe to set up PowerSync and configure mock responses (401) + const setupIdentifier = `setup-${uuid()}`; + const setupIframe = createIframeWithPowerSyncClient(dbFilename, setupIdentifier, vfs, false, true); + onTestFinished(async () => { + try { + await setupIframe.cleanup(); + } catch (e) { + // Ignore cleanup errors + } + }); + // Wait for the setup iframe to be ready (this ensures PowerSync is initialized and mock responses are configured) + await setupIframe.ready; + // Step 1: Open 100 tabs (don't wait for them to be ready) + const tabResults: IframeClientResult[] = [setupIframe]; + + for (let i = 0; i < NUM_TABS; i++) { + const identifier = `tab-${i}`; + const result = createIframeWithPowerSyncClient(dbFilename, identifier, vfs); + tabResults.push(result); + + // Register cleanup for each tab + onTestFinished(async () => { + try { + await result.cleanup(); + } catch (e) { + // Ignore cleanup errors - tab might already be closed + } + }); + } + + // Total iframes: 1 setup + NUM_TABS tabs + expect(tabResults.length).toBe(NUM_TABS + 1); + + // Verify all iframes are created (they're created immediately) + for (const result of tabResults) { + expect(result.iframe.isConnected).toBe(true); + } + + // Step 2: Wait 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Step 3: Close all tabs except the setup iframe (index 0) and the middle (50th) tab + // The middle tab is at index 1 + MIDDLE_TAB_INDEX (since index 0 is the setup iframe) + const middleTabArrayIndex = 1 + MIDDLE_TAB_INDEX; + const tabsToClose: IframeClientResult[] = []; + for (let i = 0; i < tabResults.length; i++) { + // Skip the setup iframe (index 0) and the middle tab + if (i !== 0 && i !== middleTabArrayIndex) { + tabsToClose.push(tabResults[i]); + } + } + + // Close all tabs except the setup iframe and middle one simultaneously (without waiting for ready) + const closePromises = tabsToClose.map((result) => result.cleanup()); + await Promise.all(closePromises); + + // Verify closed tabs are removed + for (let i = 0; i < tabResults.length; i++) { + if (i !== 0 && i !== middleTabArrayIndex) { + expect(tabResults[i].iframe.isConnected).toBe(false); + expect(document.body.contains(tabResults[i].iframe)).toBe(false); + } + } + + // Verify the setup iframe and middle tab are still present + expect(tabResults[0].iframe.isConnected).toBe(true); + expect(document.body.contains(tabResults[0].iframe)).toBe(true); + expect(tabResults[middleTabArrayIndex].iframe.isConnected).toBe(true); + expect(document.body.contains(tabResults[middleTabArrayIndex].iframe)).toBe(true); + + // Step 4: Wait for the middle tab to be ready, then execute a test query to verify its DB is still functional + const middleTabClient = await tabResults[middleTabArrayIndex].ready; + const queryResult = await middleTabClient.executeQuery('SELECT 1 as value'); + + // Verify the query result + expect(queryResult).toBeDefined(); + expect(Array.isArray(queryResult)).toBe(true); + expect(queryResult.length).toBe(1); + expect((queryResult[0] as { value: number }).value).toBe(1); + + // Step 5: Create another tab, wait for it to be ready, and verify its credentialsFetchCount is 1 + const newTabIdentifier = `new-tab-${Date.now()}`; + const newTabResult = createIframeWithPowerSyncClient(dbFilename, newTabIdentifier, vfs, true); + onTestFinished(async () => { + try { + await newTabResult.cleanup(); + } catch (e) { + // Ignore cleanup errors + } + }); + const newTabClient = await newTabResult.ready; + + // Verify the new tab's credentials fetch count is 1 + // This means the shared worker is using the db and attempting to connect to the PowerSync server. + const credentialsFetchCount = await newTabClient.getCredentialsFetchCount(); + expect(credentialsFetchCount).toBe(1); + }); + }); +} diff --git a/packages/web/tests/utils/MockSyncService.ts b/packages/web/tests/utils/MockSyncService.ts new file mode 100644 index 000000000..d6755b18e --- /dev/null +++ b/packages/web/tests/utils/MockSyncService.ts @@ -0,0 +1,3 @@ +// Re-export types and worker-side implementation +export * from './MockSyncServiceTypes'; +export * from './MockSyncServiceWorker'; diff --git a/packages/web/tests/utils/MockSyncServiceClient.ts b/packages/web/tests/utils/MockSyncServiceClient.ts new file mode 100644 index 000000000..c099e5b77 --- /dev/null +++ b/packages/web/tests/utils/MockSyncServiceClient.ts @@ -0,0 +1,228 @@ +import { StreamingSyncLine } from '@powersync/common'; +import type { + AutomaticResponseConfig, + MockSyncServiceMessage, + MockSyncServiceResponse, + PendingRequest +} from './MockSyncServiceTypes'; + +/** + * Interface for mocking sync service responses in shared worker environments. + * Similar to MockSyncService in the node SDK package. + */ +export interface MockSyncService { + /** + * Get all pending requests (requests waiting for a response to be created) + */ + getPendingRequests(): Promise; + + /** + * Create a response for a pending request with the specified status and headers. + * This resolves the pending request and allows pushing body data. + */ + createResponse(pendingRequestId: string, status: number, headers: Record): Promise; + + /** + * Push body data to an active response. + * Accepts either text (string) or binary data (ArrayBuffer or Uint8Array). + * Strings are encoded to Uint8Array before sending. + * The response must have been created first using createResponse. + */ + pushBodyData(pendingRequestId: string, data: string | ArrayBuffer | Uint8Array): Promise; + + /** + * Push a streaming sync line as NDJSON to an active response. + * This is a convenience method that encodes the line as JSON with a newline. + * The response must have been created first using createResponse. + */ + pushBodyLine(pendingRequestId: string, line: StreamingSyncLine): Promise; + + /** + * Complete an active response (close the stream). + * The response must have been created first using createResponse. + */ + completeResponse(pendingRequestId: string): Promise; + + /** + * Set the automatic response configuration. + * When set, this will be used to automatically reply to all pending requests. + */ + setAutomaticResponse(config: AutomaticResponseConfig | null): Promise; + + /** + * Automatically reply to all pending requests using the automatic response configuration. + * Returns the number of requests that were replied to. + */ + replyToAllPendingRequests(): Promise; +} + +/** + * Connect to the shared worker and get access to the mock sync service. + * This function creates a separate SharedWorker connection to the same shared sync worker + * just to access the mock service, without interfering with the normal sync implementation. + * + * @param identifier - The database identifier (used to construct the worker name) + * @param workerUrl - Optional custom worker URL. If not provided, uses the default shared sync worker. + * @returns The mock sync service interface, or null if not available + */ +export async function getMockSyncServiceFromWorker( + identifier: string, + workerUrl?: string | URL +): Promise { + // Create a separate SharedWorker connection to the same shared sync worker + // This connection is only used to access the mock service + // Note the URL and identifier should match in order for the correct worker to be used + const worker = workerUrl + ? new SharedWorker(typeof workerUrl === 'string' ? workerUrl : workerUrl.href, { + name: `shared-sync-${identifier}` + }) + : new SharedWorker(new URL('../../lib/src/worker/sync/SharedSyncImplementation.worker.js', import.meta.url), { + /* @vite-ignore */ + name: `shared-sync-${identifier}`, + type: 'module' + }); + + const port = worker.port; + port.start(); + + // Generic helper to send a message and wait for a response + const sendMessage = ( + message: MockSyncServiceMessage, + expectedType: T['type'], + timeout = 5000, + transfer?: Transferable[] + ): Promise => { + return new Promise((resolve, reject) => { + const requestId = 'requestId' in message ? message.requestId : undefined; + + const handler = (event: MessageEvent) => { + const response = event.data; + + if (response.type === expectedType && response.requestId === requestId) { + port.removeEventListener('message', handler); + if ('success' in response && !response.success) { + reject(new Error('Operation failed')); + } else { + resolve(response as T); + } + } else if (response.type === 'error' && response.requestId === requestId) { + port.removeEventListener('message', handler); + reject(new Error(response.error)); + } + }; + + port.addEventListener('message', handler); + if (transfer && transfer.length > 0) { + port.postMessage(message, transfer); + } else { + port.postMessage(message); + } + + // Timeout + setTimeout(() => { + port.removeEventListener('message', handler); + reject(new Error(`Timeout waiting for ${expectedType} response`)); + }, timeout); + }); + }; + + // Define pushBodyData first so it can be used by pushBodyLine + const pushBodyData = async (pendingRequestId: string, data: string | ArrayBuffer | Uint8Array): Promise => { + const requestId = crypto.randomUUID(); + + // Handle transferable objects for ArrayBuffer + const transfer: Transferable[] = []; + if (data instanceof ArrayBuffer) { + transfer.push(data); + } else if (data instanceof Uint8Array && data.buffer instanceof ArrayBuffer) { + transfer.push(data.buffer); + } + // Strings are passed as-is, no transfer needed + + await sendMessage( + { + type: 'pushBodyData', + requestId, + pendingRequestId, + data + } satisfies MockSyncServiceMessage, + 'pushBodyData', + 5000, + transfer.length > 0 ? transfer : undefined + ); + }; + + return { + async getPendingRequests(): Promise { + const requestId = crypto.randomUUID(); + const response = await sendMessage<{ type: 'getPendingRequests'; requestId: string; requests: PendingRequest[] }>( + { type: 'getPendingRequests', requestId } satisfies MockSyncServiceMessage, + 'getPendingRequests' + ); + return response.requests; + }, + + async createResponse(pendingRequestId: string, status: number, headers: Record): Promise { + const requestId = crypto.randomUUID(); + await sendMessage( + { + type: 'createResponse', + requestId, + pendingRequestId, + status, + headers + } satisfies MockSyncServiceMessage, + 'createResponse' + ); + }, + + pushBodyData, + + async pushBodyLine(pendingRequestId: string, line: any): Promise { + // Encode as NDJSON: JSON.stringify + newline + const lineStr = `${JSON.stringify(line)}\n`; + await pushBodyData(pendingRequestId, lineStr); + }, + + async completeResponse(pendingRequestId: string): Promise { + const requestId = crypto.randomUUID(); + await sendMessage( + { + type: 'completeResponse', + requestId, + pendingRequestId + } satisfies MockSyncServiceMessage, + 'completeResponse' + ); + }, + + async setAutomaticResponse(config: AutomaticResponseConfig | null): Promise { + const requestId = crypto.randomUUID(); + await sendMessage( + { + type: 'setAutomaticResponse', + requestId, + config + } satisfies MockSyncServiceMessage, + 'setAutomaticResponse' + ); + }, + + async replyToAllPendingRequests(): Promise { + const requestId = crypto.randomUUID(); + const response = await sendMessage<{ + type: 'replyToAllPendingRequests'; + requestId: string; + success: boolean; + count: number; + }>( + { + type: 'replyToAllPendingRequests', + requestId + } satisfies MockSyncServiceMessage, + 'replyToAllPendingRequests' + ); + return response.count; + } + }; +} diff --git a/packages/web/tests/utils/MockSyncServiceTypes.ts b/packages/web/tests/utils/MockSyncServiceTypes.ts new file mode 100644 index 000000000..8f0d61d79 --- /dev/null +++ b/packages/web/tests/utils/MockSyncServiceTypes.ts @@ -0,0 +1,71 @@ +/** + * Representation of a pending request + */ +export interface PendingRequest { + id: string; + url: string; + method: string; + headers: Record; + body: any; +} + +/** + * Automatic response configuration + */ +export interface AutomaticResponseConfig { + status: number; + headers: Record; + bodyLines?: any[]; +} + +/** + * Message types for communication via MessagePort + */ +export type MockSyncServiceMessage = + | { type: 'getPendingRequests'; requestId: string } + | { + type: 'createResponse'; + requestId: string; + pendingRequestId: string; + status: number; + headers: Record; + } + | { type: 'pushBodyData'; requestId: string; pendingRequestId: string; data: string | ArrayBuffer | Uint8Array } + | { type: 'completeResponse'; requestId: string; pendingRequestId: string } + | { type: 'setAutomaticResponse'; requestId: string; config: AutomaticResponseConfig | null } + | { type: 'replyToAllPendingRequests'; requestId: string }; + +export type MockSyncServiceResponse = + | { type: 'getPendingRequests'; requestId: string; requests: PendingRequest[] } + | { type: 'createResponse'; requestId: string; success: boolean } + | { type: 'pushBodyData'; requestId: string; success: boolean } + | { type: 'completeResponse'; requestId: string; success: boolean } + | { type: 'setAutomaticResponse'; requestId: string; success: boolean } + | { type: 'replyToAllPendingRequests'; requestId: string; success: boolean; count: number } + | { type: 'error'; requestId?: string; error: string }; + +/** + * Internal representation of a pending request with response promise + */ +export interface PendingRequestInternal { + id: string; + url: string; + method: string; + headers: Record; + body: any; + responsePromise: { + resolve: (response: Response) => void; + reject: (error: Error) => void; + }; + streamController?: ReadableStreamDefaultController; +} + +/** + * Internal representation of an active response + */ +export interface ActiveResponse { + id: string; + status: number; + headers: Record; + stream: ReadableStreamDefaultController; +} diff --git a/packages/web/tests/utils/MockSyncServiceWorker.ts b/packages/web/tests/utils/MockSyncServiceWorker.ts new file mode 100644 index 000000000..9387d3064 --- /dev/null +++ b/packages/web/tests/utils/MockSyncServiceWorker.ts @@ -0,0 +1,406 @@ +import type { MockSyncServiceMessage, MockSyncServiceResponse } from './MockSyncServiceTypes'; +import { + ActiveResponse, + AutomaticResponseConfig, + PendingRequest, + PendingRequestInternal +} from './MockSyncServiceTypes'; + +/** + * Mock sync service implementation for shared worker environments. + * This allows tests to mock sync responses when using enableMultipleTabs: true. + * Requests are kept pending until a client explicitly creates a response. + */ +export class MockSyncService { + private pendingRequests: Map = new Map(); + private activeResponses: Map = new Map(); + private nextId = 0; + private automaticResponse: AutomaticResponseConfig | null = null; + + /** + * A Static instance of the mock sync service. + * This can be used directly for non-worker environments. + * A proxy is required for worker environments. + */ + static readonly GLOBAL_INSTANCE = new MockSyncService(); + + /** + * Register a new pending request (called by WebRemote when a sync stream is requested). + * Returns a promise that resolves when a client creates a response for this request. + */ + registerPendingRequest( + url: string, + method: string, + headers: Record, + body: any, + signal?: AbortSignal + ): Promise { + const id = `pending-${++this.nextId}`; + + let resolveResponse: (response: Response) => void; + let rejectResponse: (error: Error) => void; + + const responsePromise = new Promise((resolve, reject) => { + resolveResponse = resolve; + rejectResponse = reject; + }); + + const pendingRequest: PendingRequestInternal = { + id, + url, + method, + headers, + body, + responsePromise: { + resolve: resolveResponse!, + reject: rejectResponse! + } + }; + + this.pendingRequests.set(id, pendingRequest); + + signal?.addEventListener('abort', () => { + this.pendingRequests.delete(id); + rejectResponse(new Error('Request aborted')); + + // if already in active responses, remove it + if (this.activeResponses.has(id)) { + const response = this.activeResponses.get(id); + if (response) { + response.stream.close(); + } + this.activeResponses.delete(id); + } + }); + + // If automatic response is configured, apply it immediately + if (this.automaticResponse) { + // Use setTimeout to ensure the response is created asynchronously + // This prevents issues if the response creation happens synchronously + setTimeout(() => { + try { + // Create response with automatic config + this.createResponse(id, this.automaticResponse!.status, this.automaticResponse!.headers); + + // Push body lines if provided + if (this.automaticResponse!.bodyLines) { + for (const line of this.automaticResponse!.bodyLines) { + const lineStr = `${JSON.stringify(line)}\n`; + const encoder = new TextEncoder(); + this.pushBodyData(id, encoder.encode(lineStr)); + } + } + + // Complete the response + this.completeResponse(id); + } catch (e) { + // If automatic response fails, reject the promise + rejectResponse!(e instanceof Error ? e : new Error(String(e))); + } + }, 0); + } + + // Return the promise - it will resolve when createResponse is called (or immediately if auto-response is set) + return responsePromise; + } + + /** + * Get all pending requests + */ + getPendingRequestsSync(): PendingRequest[] { + return Array.from(this.pendingRequests.values()).map((pr) => ({ + id: pr.id, + url: pr.url, + method: pr.method, + headers: pr.headers, + body: pr.body + })); + } + + /** + * Create a response for a pending request. + * This resolves the response promise and allows pushing body lines. + */ + createResponse(pendingRequestId: string, status: number, headers: Record): void { + const pendingRequest = this.pendingRequests.get(pendingRequestId); + if (!pendingRequest) { + throw new Error(`Pending request ${pendingRequestId} not found`); + } + + // Create a readable stream that the mock service can control + // Response.body is always ReadableStream, so we use Uint8Array + const stream = new ReadableStream({ + start: (controller) => { + // Store the active response once the controller is available + // The start callback is called synchronously, so this is safe + const activeResponse: ActiveResponse = { + id: pendingRequestId, + status, + headers, + stream: controller + }; + this.activeResponses.set(pendingRequestId, activeResponse); + }, + cancel: () => { + // Remove response when stream is cancelled + this.activeResponses.delete(pendingRequestId); + this.pendingRequests.delete(pendingRequestId); + } + }); + + // Create the Response object + const response = new Response(stream, { + status, + headers + }); + + // Resolve the pending request's promise + pendingRequest.responsePromise.resolve(response); + + // Remove from pending (it's now active) + this.pendingRequests.delete(pendingRequestId); + } + + /** + * Push body data to an active response. + * Accepts either text (string) or binary data (ArrayBuffer or Uint8Array). + * All data is encoded to Uint8Array before enqueueing (required by ReadableStream). + */ + pushBodyData(pendingRequestId: string, data: string | ArrayBuffer | Uint8Array): void { + const activeResponse = this.activeResponses.get(pendingRequestId); + if (!activeResponse) { + throw new Error(`Active response ${pendingRequestId} not found`); + } + + try { + let encoded: Uint8Array; + + if (typeof data === 'string') { + // Encode string to Uint8Array (required by ReadableStream) + const encoder = new TextEncoder(); + encoded = encoder.encode(data); + } else if (data instanceof ArrayBuffer) { + // Convert ArrayBuffer to Uint8Array + encoded = new Uint8Array(data); + } else { + // Already Uint8Array, use directly + encoded = data; + } + + activeResponse.stream.enqueue(encoded); + } catch (e) { + // Stream might be closed, remove it + this.activeResponses.delete(pendingRequestId); + throw new Error(`Failed to push data to response ${pendingRequestId}: ${e}`); + } + } + + /** + * Complete an active response (close the stream) + */ + completeResponse(pendingRequestId: string): void { + const activeResponse = this.activeResponses.get(pendingRequestId); + if (!activeResponse) { + throw new Error(`Active response ${pendingRequestId} not found`); + } + + try { + activeResponse.stream.close(); + } catch (e) { + // Stream might already be closed + } finally { + this.activeResponses.delete(pendingRequestId); + } + } + + /** + * Set the automatic response configuration. + * When set, this will be used to automatically reply to all pending requests. + */ + setAutomaticResponse(config: AutomaticResponseConfig | null): void { + this.automaticResponse = config; + } + + /** + * Automatically reply to all pending requests using the automatic response configuration. + * Returns the number of requests that were replied to. + */ + replyToAllPendingRequests(): number { + if (!this.automaticResponse) { + throw new Error('Automatic response not set. Call setAutomaticResponse first.'); + } + + const pendingRequestIds = Array.from(this.pendingRequests.keys()); + let count = 0; + + for (const requestId of pendingRequestIds) { + try { + // Create response with automatic config + this.createResponse(requestId, this.automaticResponse.status, this.automaticResponse.headers); + + // Push body lines if provided + if (this.automaticResponse.bodyLines) { + for (const line of this.automaticResponse.bodyLines) { + const lineStr = `${JSON.stringify(line)}\n`; + const encoder = new TextEncoder(); + this.pushBodyData(requestId, encoder.encode(lineStr)); + } + } + + // Complete the response + this.completeResponse(requestId); + count++; + } catch (e) { + // Skip requests that fail (might already be handled) + continue; + } + } + + return count; + } +} + +/** + * Set up message handler for the mock service on a MessagePort + */ +export function setupMockServiceMessageHandler(port: MessagePort) { + port.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + + if (!message || typeof message !== 'object' || !('type' in message)) { + return; + } + + const service = MockSyncService.GLOBAL_INSTANCE; + + try { + switch (message.type) { + case 'getPendingRequests': { + try { + const requests = service.getPendingRequestsSync(); + port.postMessage({ + type: 'getPendingRequests', + requestId: message.requestId, + requests + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + case 'createResponse': { + try { + service.createResponse(message.pendingRequestId, message.status, message.headers); + port.postMessage({ + type: 'createResponse', + requestId: message.requestId, + success: true + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + case 'pushBodyData': { + try { + service.pushBodyData(message.pendingRequestId, message.data); + port.postMessage({ + type: 'pushBodyData', + requestId: message.requestId, + success: true + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + case 'completeResponse': { + try { + service.completeResponse(message.pendingRequestId); + port.postMessage({ + type: 'completeResponse', + requestId: message.requestId, + success: true + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + case 'setAutomaticResponse': { + try { + service.setAutomaticResponse(message.config); + port.postMessage({ + type: 'setAutomaticResponse', + requestId: message.requestId, + success: true + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + case 'replyToAllPendingRequests': { + try { + const count = service.replyToAllPendingRequests(); + port.postMessage({ + type: 'replyToAllPendingRequests', + requestId: message.requestId, + success: true, + count + } satisfies MockSyncServiceResponse); + } catch (error) { + port.postMessage({ + type: 'error', + requestId: message.requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + break; + } + default: { + const requestId = + 'requestId' in message && typeof message === 'object' && message !== null + ? (message as { requestId?: string }).requestId + : undefined; + port.postMessage({ + type: 'error', + requestId, + error: `Unknown message type: ${(message as any).type}` + } satisfies MockSyncServiceResponse); + break; + } + } + } catch (error) { + // Fallback for any unexpected errors + const requestId = 'requestId' in message ? message.requestId : undefined; + port.postMessage({ + type: 'error', + requestId, + error: error instanceof Error ? error.message : String(error) + } satisfies MockSyncServiceResponse); + } + }); + + port.start(); +} diff --git a/packages/web/tests/utils/iframeInitializer.ts b/packages/web/tests/utils/iframeInitializer.ts new file mode 100644 index 000000000..4c1c8da11 --- /dev/null +++ b/packages/web/tests/utils/iframeInitializer.ts @@ -0,0 +1,173 @@ +import { LogLevel, Schema, SyncStreamConnectionMethod, TableV2, column, createBaseLogger } from '@powersync/common'; +import { PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; +import { getMockSyncServiceFromWorker } from './MockSyncServiceClient'; + +/** + * Initializes a PowerSync client in the current iframe context and notifies the parent. + * This function is designed to be called from within an iframe's script tag. + * + * @param vfs - VFS option as a string (e.g., 'OPFSCoopSyncVFS' or 'IDBBatchAtomicVFS') + */ +export async function setupPowerSyncInIframe( + dbFilename: string, + identifier: string, + vfs?: string, + waitForConnection?: boolean, + configureMockResponses?: boolean +): Promise { + try { + // Track the number of times fetchCredentials has been called + let credentialsFetchCount = 0; + + const connector = { + async fetchCredentials() { + credentialsFetchCount++; + return { endpoint: 'http://localhost/test', token: 'test-token' }; + }, + async uploadData() {} + }; + + // Create a simple schema for testing + const schema = new Schema({ + customers: new TableV2({ + name: column.text, + email: column.text + }) + }); + + // Configure database with optional VFS + // The vfs string value is the enum value itself (string enums) + const databaseOptions = vfs + ? new WASQLiteOpenFactory({ + dbFilename, + vfs: vfs as WASQLiteVFS + }) + : { dbFilename }; + + // Configure verbose logging + const logger = createBaseLogger(); + logger.setLevel(LogLevel.DEBUG); + logger.useDefaults(); + + const db = new PowerSyncDatabase({ + database: databaseOptions, + schema: schema, + retryDelayMs: 100, + flags: { enableMultiTabs: true, useWebWorker: true }, + logger + }); + + // Connect to PowerSync (don't await this since we want to create multiple tabs) + const connectionPromise = db.connect(connector, { connectionMethod: SyncStreamConnectionMethod.HTTP }); + + if (waitForConnection) { + await connectionPromise; + } + + if (configureMockResponses) { + // Wait for connecting:true before setting up mock responses + const maxAttempts = 100; + const delayMs = 50; + for (let i = 0; i < maxAttempts; i++) { + if (db.currentStatus.connecting) { + break; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + const mockSyncService = await getMockSyncServiceFromWorker(dbFilename); + if (mockSyncService) { + await mockSyncService.setAutomaticResponse({ + // We want to confirm credentials are fetched due to invalidation. + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + await mockSyncService.replyToAllPendingRequests(); + } + } + + // Store reference for cleanup + (window as any).powersyncClient = db; + + // Set up message handlers for test operations + window.addEventListener('message', async (event: MessageEvent) => { + // Only handle messages from parent window + // Note: event.source might not match exactly with blob URLs, so we'll be less strict + if (event.source && event.source !== window.parent && event.source !== window) { + return; + } + + const { type, requestId, query, parameters } = event.data || {}; + + if (type === 'execute-query' && requestId) { + try { + const result = await db.getAll(query, parameters || []); + window.parent.postMessage( + { + type: 'query-result', + requestId, + identifier, + success: true, + result + }, + '*' + ); + } catch (error) { + window.parent.postMessage( + { + type: 'query-result', + requestId, + identifier, + success: false, + error: (error as Error).message + }, + '*' + ); + } + } else if (type === 'get-credentials-count' && requestId) { + try { + window.parent.postMessage( + { + type: 'credentials-count-result', + requestId, + identifier, + success: true, + count: credentialsFetchCount + }, + '*' + ); + } catch (error) { + window.parent.postMessage( + { + type: 'credentials-count-result', + requestId, + identifier, + success: false, + error: (error as Error).message + }, + '*' + ); + } + } + }); + + // Notify parent that client is ready + window.parent.postMessage( + { + type: 'powersync-ready', + identifier: identifier + }, + '*' + ); + } catch (error) { + console.error('PowerSync initialization error:', error); + window.parent.postMessage( + { + type: 'powersync-error', + identifier: identifier, + error: (error as Error).message + }, + '*' + ); + } +} diff --git a/packages/web/tests/utils/mockSyncServiceTest.ts b/packages/web/tests/utils/mockSyncServiceTest.ts new file mode 100644 index 000000000..2cac92e53 --- /dev/null +++ b/packages/web/tests/utils/mockSyncServiceTest.ts @@ -0,0 +1,160 @@ +import { + LogLevel, + PowerSyncBackendConnector, + PowerSyncCredentials, + Schema, + SyncStreamConnectionMethod, + Table, + column, + createBaseLogger +} from '@powersync/common'; +import { PowerSyncDatabase, WebPowerSyncDatabaseOptions } from '@powersync/web'; +import { MockedFunction, expect, onTestFinished, test, vi } from 'vitest'; +import { MockSyncService, getMockSyncServiceFromWorker } from './MockSyncServiceClient'; + +// Define schema similar to node tests +const lists = new Table({ + name: column.text +}); + +export const AppSchema = new Schema({ + lists +}); + +export type MockedTestConnector = { + [Key in keyof PowerSyncBackendConnector]: MockedFunction; +}; +/** + * Creates a test connector with vi.fn implementations for testing. + */ +export function createTestConnector(): MockedTestConnector { + return { + fetchCredentials: vi.fn().mockResolvedValue({ + endpoint: 'http://localhost:3000', + token: 'test-token' + } as PowerSyncCredentials), + uploadData: vi.fn().mockResolvedValue(undefined) + }; +} + +/** + * Result of calling the connect function + */ +export interface ConnectResult { + syncRequestId: string; +} + +/** + * Vitest test extension for mocking sync service in shared worker environments. + * Similar to mockSyncServiceTest in the node SDK package. + * + * This extension: + * - Sets up a PowerSync database with the lists schema + * - Exposes a connect function that calls powersync.connect(), waits for connecting: true, + * creates the sync service, and returns both + * - Exposes the database and test connector + */ +export const sharedMockSyncServiceTest = test.extend<{ + context: { + /** An automatically opened database */ + connector: MockedTestConnector; + connect: (customConnector?: PowerSyncBackendConnector) => Promise; + database: PowerSyncDatabase; + databaseName: string; + openDatabase: (customConfig?: Partial) => PowerSyncDatabase; + mockService: MockSyncService; + }; +}>({ + context: async ({}, use) => { + const dbFilename = `test-${crypto.randomUUID()}.db`; + const logger = createBaseLogger(); + logger.setLevel(LogLevel.DEBUG); + logger.useDefaults(); + + const openDatabase = (customConfig: Partial = {}) => { + const db = new PowerSyncDatabase({ + database: { + dbFilename, + ...(customConfig.database ?? {}) + }, + flags: { + enableMultiTabs: true, + ...(customConfig.flags ?? {}) + }, + retryDelayMs: 1000, + crudUploadThrottleMs: 1000, + schema: AppSchema, + logger, + ...customConfig + }); + onTestFinished(async () => { + if (!db.closed) { + await db.disconnect(); + await db.close(); + } + }); + return db; + }; + + const database = openDatabase(); + + // Get the identifier from the database.name property + const identifier = database.database.name; + + // Connect to the shared worker to get the mock service + const mockService = await getMockSyncServiceFromWorker(identifier); + if (!mockService) { + throw new Error('Mock service not available'); + } + + const connector = createTestConnector(); + + const connectFn = async (customConnector?: PowerSyncBackendConnector): Promise => { + const connectorToUse = customConnector ?? connector; + + // Call powersync.connect() to start the sync worker + const connectionPromise = database.connect(connectorToUse, { + connectionMethod: SyncStreamConnectionMethod.HTTP + }); + + // Wait for the database to report connecting: true before using the sync service + await vi.waitFor( + () => { + expect(database.connecting).toBe(true); + }, + { timeout: 1000 } + ); + + let _syncRequestId: string; + await vi.waitFor(async () => { + const requests = await mockService.getPendingRequests(); + expect(requests.length).toBeGreaterThan(0); + _syncRequestId = requests[0].id; + }); + + const syncRequestId = _syncRequestId!; + + await mockService.createResponse(syncRequestId, 200, { 'Content-Type': 'application/json' }); + + // Send a Keepalive just as the first message + await mockService.pushBodyLine(syncRequestId, { + token_expires_in: 10_000_000 + }); + + await connectionPromise; + + return { + syncRequestId + }; + }; + + await use({ + connector, + connect: connectFn, + database, + databaseName: dbFilename, + openDatabase, + mockService + }); + } +}); diff --git a/packages/web/vitest.config.ts b/packages/web/vitest.config.ts index 0e586da8d..31b3acc55 100644 --- a/packages/web/vitest.config.ts +++ b/packages/web/vitest.config.ts @@ -20,7 +20,9 @@ const config: UserConfigExport = { */ '@powersync/web': path.resolve(__dirname, './lib/src'), // https://jira.mongodb.org/browse/NODE-5773 - bson: require.resolve('bson') + bson: require.resolve('bson'), + // Mock WebRemote to throw 401 errors for all HTTP requests in tests + '../../db/sync/WebRemote': path.resolve(__dirname, './tests/mocks/MockWebRemote.ts') } }, worker: { @@ -31,7 +33,7 @@ const config: UserConfigExport = { // Don't optimise these packages as they contain web workers and WASM files. // https://github.com/vitejs/vite/issues/11672#issuecomment-1415820673 exclude: ['@journeyapps/wa-sqlite', '@powersync/web'], - include: ['bson', 'comlink', 'async-mutex'] + include: [] }, plugins: [wasm(), topLevelAwait()], test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 250b47e6c..394b0ad36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,7 +281,7 @@ importers: version: link:../common devDependencies: '@journeyapps/wa-sqlite': - specifier: ^1.4.0 + specifier: ^1.4.1 version: 1.4.1 '@powersync/web': specifier: workspace:* @@ -312,7 +312,7 @@ importers: version: 0.28.9 devDependencies: '@journeyapps/wa-sqlite': - specifier: ^1.4.0 + specifier: ^1.4.1 version: 1.4.1 '@powersync/web': specifier: workspace:* @@ -575,7 +575,7 @@ importers: version: 12.1.0 devDependencies: '@journeyapps/wa-sqlite': - specifier: ^1.4.0 + specifier: ^1.4.1 version: 1.4.1 '@types/uuid': specifier: ^9.0.6 @@ -629,7 +629,7 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.1)(react@18.3.1))(@types/react@18.3.1)(react@18.3.1) '@journeyapps/wa-sqlite': - specifier: ^1.4.0 + specifier: ^1.4.1 version: 1.4.1 '@mui/icons-material': specifier: ^7.3.5 diff --git a/scripts/demos-inject-local.ts b/scripts/demos-inject-local.ts index 9e8b93e9a..18211b9d5 100644 --- a/scripts/demos-inject-local.ts +++ b/scripts/demos-inject-local.ts @@ -1,9 +1,9 @@ +import { findWorkspacePackages } from '@pnpm/workspace.find-packages'; import { Command, type OptionValues } from 'commander'; +import inquirer from 'inquirer'; +import { execSync } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { execSync } from 'node:child_process'; -import inquirer from 'inquirer'; -import { findWorkspacePackages } from '@pnpm/workspace.find-packages'; // Get workspace packages and create a mapping from package name to local path const workspacePackages = await findWorkspacePackages(path.resolve('.'), { @@ -90,7 +90,7 @@ const filterDemosUser = async (allDemos: string[], _options: OptionValues): Prom }; const filterDemosPattern = (demos: string[], options: OptionValues): string[] => { - return demos.filter((demo) => demo.includes(options.filter)); + return demos.filter((demo) => demo.includes(options.pattern)); }; const injectPackagesAll = async (demos: string[], options: OptionValues) => { diff --git a/tools/diagnostics-app/package.json b/tools/diagnostics-app/package.json index 721b10666..b703a2593 100644 --- a/tools/diagnostics-app/package.json +++ b/tools/diagnostics-app/package.json @@ -11,7 +11,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@journeyapps/wa-sqlite": "^1.4.0", + "@journeyapps/wa-sqlite": "^1.4.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^5.15.12", "@mui/x-data-grid": "^6.19.6", diff --git a/tools/powersynctests/android/app/build.gradle b/tools/powersynctests/android/app/build.gradle index 178195480..738535536 100644 --- a/tools/powersynctests/android/app/build.gradle +++ b/tools/powersynctests/android/app/build.gradle @@ -8,12 +8,12 @@ apply plugin: "com.facebook.react" */ react { /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' // root = file("../../") - // The folder where the react-native NPM package is. Default is ../../node_modules/react-native - // reactNativeDir = file("../../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen - // codegenDir = file("../../node_modules/@react-native/codegen") + // Resolve react-native and codegen from the monorepo root using node's resolution + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js // cliFile = file("../../node_modules/react-native/cli.js") @@ -45,7 +45,7 @@ react { /* Hermes Commands */ // The hermes compiler command to run. By default it is 'hermesc' - // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] diff --git a/tools/powersynctests/android/settings.gradle b/tools/powersynctests/android/settings.gradle index 46461f01e..b238cc81e 100644 --- a/tools/powersynctests/android/settings.gradle +++ b/tools/powersynctests/android/settings.gradle @@ -1,9 +1,9 @@ -pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +pluginManagement { includeBuild("../../../node_modules/@react-native/gradle-plugin") } plugins { id("com.facebook.react.settings") } extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'powersynctests' include ':app' -includeBuild('../node_modules/@react-native/gradle-plugin') +includeBuild('../../../node_modules/@react-native/gradle-plugin') include ':react-native-vector-icons' project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../../../node_modules/react-native-vector-icons/android') include ':powersync_op-sqlite' diff --git a/tools/powersynctests/ios/Podfile.lock b/tools/powersynctests/ios/Podfile.lock index 09a8d7e60..171cd6fb4 100644 --- a/tools/powersynctests/ios/Podfile.lock +++ b/tools/powersynctests/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.78.0): - hermes-engine/Pre-built (= 0.78.0) - hermes-engine/Pre-built (0.78.0) - - op-sqlite (14.0.2): + - op-sqlite (14.1.4): - DoubleConversion - glog - hermes-engine @@ -31,11 +31,11 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - powersync-op-sqlite (0.7.9): + - powersync-op-sqlite (0.7.17): - DoubleConversion - glog - hermes-engine - - powersync-sqlite-core (~> 0.4.6) + - powersync-sqlite-core (~> 0.4.10) - RCT-Folly (= 2024.11.18.00) - RCTRequired - RCTTypeSafety @@ -55,7 +55,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - powersync-sqlite-core (0.4.6) + - powersync-sqlite-core (0.4.10) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -1575,7 +1575,7 @@ PODS: - React-logger (= 0.78.0) - React-perflogger (= 0.78.0) - React-utils (= 0.78.0) - - RNVectorIcons (10.2.0): + - RNVectorIcons (10.3.0): - DoubleConversion - glog - hermes-engine @@ -1600,78 +1600,78 @@ PODS: - Yoga (0.0.0) DEPENDENCIES: - - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) + - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) + - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) + - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) + - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - "op-sqlite (from `../../../node_modules/@op-engineering/op-sqlite`)" - "powersync-op-sqlite (from `../node_modules/@powersync/op-sqlite`)" - - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../node_modules/react-native/`) - - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../node_modules/react-native/`) - - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) - - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCT-Folly/Fabric (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../../node_modules/react-native/`) + - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../../node_modules/react-native/`) + - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) + - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-get-random-values (from `../../../node_modules/react-native-get-random-values`) - - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../node_modules/react-native/React`) - - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) - - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) - - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-rncore (from `../node_modules/react-native/ReactCommon`) - - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) + - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) + - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-rncore (from `../../../node_modules/react-native/ReactCommon`) + - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - RNVectorIcons (from `../../../node_modules/react-native-vector-icons`) - - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: @@ -1680,146 +1680,146 @@ SPEC REPOS: EXTERNAL SOURCES: boost: - :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" DoubleConversion: - :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: - :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/fast_float.podspec" FBLazyVector: - :path: "../node_modules/react-native/Libraries/FBLazyVector" + :path: "../../../node_modules/react-native/Libraries/FBLazyVector" fmt: - :podspec: "../node_modules/react-native/third-party-podspecs/fmt.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec" glog: - :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec" hermes-engine: - :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-01-13-RNv0.78.0-a942ef374897d85da38e9c8904574f8376555388 op-sqlite: :path: "../../../node_modules/@op-engineering/op-sqlite" powersync-op-sqlite: :path: "../node_modules/@powersync/op-sqlite" RCT-Folly: - :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: - :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: - :path: "../node_modules/react-native/Libraries/Required" + :path: "../../../node_modules/react-native/Libraries/Required" RCTTypeSafety: - :path: "../node_modules/react-native/Libraries/TypeSafety" + :path: "../../../node_modules/react-native/Libraries/TypeSafety" React: - :path: "../node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-callinvoker: - :path: "../node_modules/react-native/ReactCommon/callinvoker" + :path: "../../../node_modules/react-native/ReactCommon/callinvoker" React-Core: - :path: "../node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-CoreModules: - :path: "../node_modules/react-native/React/CoreModules" + :path: "../../../node_modules/react-native/React/CoreModules" React-cxxreact: - :path: "../node_modules/react-native/ReactCommon/cxxreact" + :path: "../../../node_modules/react-native/ReactCommon/cxxreact" React-debug: - :path: "../node_modules/react-native/ReactCommon/react/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/debug" React-defaultsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" React-domnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" React-Fabric: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricComponents: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricImage: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-featureflags: - :path: "../node_modules/react-native/ReactCommon/react/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" React-featureflagsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" React-graphics: - :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" React-hermes: - :path: "../node_modules/react-native/ReactCommon/hermes" + :path: "../../../node_modules/react-native/ReactCommon/hermes" React-idlecallbacksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" React-ImageManager: - :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" React-jserrorhandler: - :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" React-jsi: - :path: "../node_modules/react-native/ReactCommon/jsi" + :path: "../../../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: - :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" React-jsinspectortracing: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" React-jsitracing: - :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" React-logger: - :path: "../node_modules/react-native/ReactCommon/logger" + :path: "../../../node_modules/react-native/ReactCommon/logger" React-Mapbuffer: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-microtasksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" react-native-get-random-values: :path: "../../../node_modules/react-native-get-random-values" React-NativeModulesApple: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-perflogger: - :path: "../node_modules/react-native/ReactCommon/reactperflogger" + :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" React-performancetimeline: - :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" React-RCTActionSheet: - :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: - :path: "../node_modules/react-native/Libraries/NativeAnimation" + :path: "../../../node_modules/react-native/Libraries/NativeAnimation" React-RCTAppDelegate: - :path: "../node_modules/react-native/Libraries/AppDelegate" + :path: "../../../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: - :path: "../node_modules/react-native/Libraries/Blob" + :path: "../../../node_modules/react-native/Libraries/Blob" React-RCTFabric: - :path: "../node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTFBReactNativeSpec: - :path: "../node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTImage: - :path: "../node_modules/react-native/Libraries/Image" + :path: "../../../node_modules/react-native/Libraries/Image" React-RCTLinking: - :path: "../node_modules/react-native/Libraries/LinkingIOS" + :path: "../../../node_modules/react-native/Libraries/LinkingIOS" React-RCTNetwork: - :path: "../node_modules/react-native/Libraries/Network" + :path: "../../../node_modules/react-native/Libraries/Network" React-RCTSettings: - :path: "../node_modules/react-native/Libraries/Settings" + :path: "../../../node_modules/react-native/Libraries/Settings" React-RCTText: - :path: "../node_modules/react-native/Libraries/Text" + :path: "../../../node_modules/react-native/Libraries/Text" React-RCTVibration: - :path: "../node_modules/react-native/Libraries/Vibration" + :path: "../../../node_modules/react-native/Libraries/Vibration" React-rendererconsistency: - :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" React-rendererdebug: - :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" React-rncore: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-RuntimeApple: - :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" React-RuntimeCore: - :path: "../node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimeexecutor: - :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" React-RuntimeHermes: - :path: "../node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../node_modules/react-native/ReactCommon/react/timing" + :path: "../../../node_modules/react-native/ReactCommon/react/timing" React-utils: - :path: "../node_modules/react-native/ReactCommon/react/utils" + :path: "../../../node_modules/react-native/ReactCommon/react/utils" ReactAppDependencyProvider: :path: build/generated/ios ReactCodegen: :path: build/generated/ios ReactCommon: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" RNVectorIcons: :path: "../../../node_modules/react-native-vector-icons" Yoga: - :path: "../node_modules/react-native/ReactCommon/yoga" + :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 @@ -1829,71 +1829,71 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: eb93e2f488219332457c3c4eafd2738ddc7e80b8 hermes-engine: b417d2b2aee3b89b58e63e23a51e02be91dc876d - op-sqlite: af963896bb0d5393f12e07189bb39b1eaa5f4ac3 - powersync-op-sqlite: 7564883b18032cd45324926c22977452ba4dd941 - powersync-sqlite-core: 42c4a42a692b3b770a5488778789430d67a39b49 - RCT-Folly: e78785aa9ba2ed998ea4151e314036f6c49e6d82 + op-sqlite: 74596872c19af46d0fb5464daefe766f27f1db3f + powersync-op-sqlite: 476fee017a07cd4d08052850031d5f5ba7f9d719 + powersync-sqlite-core: b30017e077c91915d53faebc5f7245384df78275 + RCT-Folly: 36fe2295e44b10d831836cc0d1daec5f8abcf809 RCTDeprecation: b2eecf2d60216df56bc5e6be5f063826d3c1ee35 RCTRequired: 78522de7dc73b81f3ed7890d145fa341f5bb32ea RCTTypeSafety: c135dd2bf50402d87fd12884cbad5d5e64850edd React: b229c49ed5898dab46d60f61ed5a0bfa2ee2fadb React-callinvoker: 2ac508e92c8bd9cf834cc7d7787d94352e4af58f - React-Core: 325b4f6d9162ae8b9a6ff42fe78e260eb124180d - React-CoreModules: 558041e5258f70cd1092f82778d07b8b2ff01897 - React-cxxreact: 8fff17cbe76e6a8f9991b59552e1235429f9c74b + React-Core: 13cdd1558d0b3f6d9d5a22e14d89150280e79f02 + React-CoreModules: b07a6744f48305405e67c845ebf481b6551b712a + React-cxxreact: 1055a86c66ac35b4e80bd5fb766aed5f494dfff4 React-debug: 0a5fcdbacc6becba0521e910c1bcfdb20f32a3f6 - React-defaultsnativemodule: 618dc50a0fad41b489997c3eb7aba3a74479fd14 - React-domnativemodule: 7ba599afb6c2a7ec3eb6450153e2efe0b8747e9a - React-Fabric: 252112089d2c63308f4cbfade4010b6606db67d1 - React-FabricComponents: 3c0f75321680d14d124438ab279c64ec2a3d13c4 - React-FabricImage: 728b8061cdec2857ca885fd605ee03ad43ffca98 + React-defaultsnativemodule: 4bb28fc97fee5be63a9ebf8f7a435cfe8ba69459 + React-domnativemodule: b36a11c2597243d7563985028c51ece988d8ae33 + React-Fabric: afc561718f25b2cd800b709d934101afe376a12c + React-FabricComponents: f4e0a4e18a27bf6d39cbf2a0b42f37a92fa4e37f + React-FabricImage: 37d8e8b672eda68a19d71143eb65148084efb325 React-featureflags: 19682e02ef5861d96b992af16a19109c3dfc1200 - React-featureflagsnativemodule: 23528c7e7d50782b7ef0804168ba40bbaf1e86ab - React-graphics: fefe48f71bfe6f48fd037f59e8277b12e91b6be1 - React-hermes: a9a0c8377627b5506ef9a7b6f60a805c306e3f51 - React-idlecallbacksnativemodule: 7e2b6a3b70e042f89cd91dbd73c479bb39a72a7e - React-ImageManager: e3300996ac2e2914bf821f71e2f2c92ae6e62ae2 - React-jserrorhandler: fa75876c662e5d7e79d6efc763fc9f4c88e26986 - React-jsi: f3f51595cc4c089037b536368f016d4742bf9cf7 - React-jsiexecutor: cca6c232db461e2fd213a11e9364cfa6fdaa20eb - React-jsinspector: 2bd4c9fddf189d6ec2abf4948461060502582bef - React-jsinspectortracing: a417d8a0ad481edaa415734b4dac81e3e5ee7dc6 - React-jsitracing: 1ff7172c5b0522cbf6c98d82bdbb160e49b5804e - React-logger: 018826bfd51b9f18e87f67db1590bc510ad20664 - React-Mapbuffer: 3c11cee7737609275c7b66bd0b1de475f094cedf - React-microtasksnativemodule: 843f352b32aacbe13a9c750190d34df44c3e6c2c - react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba - React-NativeModulesApple: 88433b6946778bea9c153e27b671de15411bf225 - React-perflogger: 9e8d3c0dc0194eb932162812a168aa5dc662f418 - React-performancetimeline: 5a2d6efef52bdcefac079c7baa30934978acd023 + React-featureflagsnativemodule: d7cddf6d907b4e5ab84f9e744b7e88461656e48c + React-graphics: b0f78580cdaf5800d25437e3d41cc6c3d83b7aea + React-hermes: 71186f872c932e4574d5feb3ed754dda63a0b3bd + React-idlecallbacksnativemodule: dd2af19cdd3bc55149d17a2409ed72b694dfbe9c + React-ImageManager: a77dde8d5aa6a2b6962c702bf3a47695ef0aa32b + React-jserrorhandler: 9c14e89f12d5904257a79aaf84a70cd2e5ac07ba + React-jsi: 0775a66820496769ad83e629f0f5cce621a57fc7 + React-jsiexecutor: 2cf5ba481386803f3c88b85c63fa102cba5d769e + React-jsinspector: 8052d532bb7a98b6e021755674659802fb140cc5 + React-jsinspectortracing: bdd8fd0adcb4813663562e7874c5842449df6d8a + React-jsitracing: 2bab3bf55de3d04baf205def375fa6643c47c794 + React-logger: 795cd5055782db394f187f9db0477d4b25b44291 + React-Mapbuffer: 0502faf46cab8fb89cfc7bf3e6c6109b6ef9b5de + React-microtasksnativemodule: 663bc64e3a96c5fc91081923ae7481adc1359a78 + react-native-get-random-values: 21325b2244dfa6b58878f51f9aa42821e7ba3d06 + React-NativeModulesApple: 16fbd5b040ff6c492dacc361d49e63cba7a6a7a1 + React-perflogger: ab51b7592532a0ea45bf6eed7e6cae14a368b678 + React-performancetimeline: bc2e48198ec814d578ac8401f65d78a574358203 React-RCTActionSheet: 592674cf61142497e0e820688f5a696e41bf16dd - React-RCTAnimation: e6d669872f9b3b4ab9527aab283b7c49283236b7 - React-RCTAppDelegate: de2343fe08be4c945d57e0ecce44afcc7dd8fc03 - React-RCTBlob: 3e2dce94c56218becc4b32b627fc2293149f798d - React-RCTFabric: cac2c033381d79a5956e08550b0220cb2d78ea93 - React-RCTFBReactNativeSpec: d10ca5e0ccbfeac8c047361fedf8e4ac653887b6 - React-RCTImage: dc04b176c022d12a8f55ae7a7279b1e091066ae0 - React-RCTLinking: 88f5e37fe4f26fbc80791aa2a5f01baf9b9a3fd5 - React-RCTNetwork: f213693565efbd698b8e9c18d700a514b49c0c8e - React-RCTSettings: a2d32a90c45a3575568cad850abc45924999b8a5 - React-RCTText: 54cdcd1cbf6f6a91dc6317f5d2c2b7fc3f6bf7a0 - React-RCTVibration: 11dae0e7f577b5807bb7d31e2e881eb46f854fd4 + React-RCTAnimation: 8fbb8dba757b49c78f4db403133ab6399a4ce952 + React-RCTAppDelegate: 7f88baa8cb4e5d6c38bb4d84339925c70c9ac864 + React-RCTBlob: f89b162d0fe6b570a18e755eb16cbe356d3c6d17 + React-RCTFabric: 8ad6d875abe6e87312cef90e4b15ef7f6bed72e6 + React-RCTFBReactNativeSpec: 8c29630c2f379c729300e4c1e540f3d1b78d1936 + React-RCTImage: ccac9969940f170503857733f9a5f63578e106e1 + React-RCTLinking: d82427bbf18415a3732105383dff119131cadd90 + React-RCTNetwork: 12ad4d0fbde939e00251ca5ca890da2e6825cc3c + React-RCTSettings: e7865bf9f455abf427da349c855f8644b5c39afa + React-RCTText: 2cdfd88745059ec3202a0842ea75a956c7d6f27d + React-RCTVibration: a3a1458e6230dfd64b3768ebc0a4aac430d9d508 React-rendererconsistency: 64e897e00d2568fd8dfe31e2496f80e85c0aaad1 - React-rendererdebug: 41ce452460c44bba715d9e41d5493a96de277764 + React-rendererdebug: a3f6d3ae7d2fa0035885026756281c07ee32479e React-rncore: 58748c2aa445f56b99e5118dad0aedb51c40ce9f - React-RuntimeApple: 7785ed0d8ae54da65a88736bb63ca97608a6d933 - React-RuntimeCore: 6029ea70bc77f98cfd43ebe69217f14e93ba1f12 + React-RuntimeApple: f0fda7bacabd32daa099cfda8f07466c30acd149 + React-RuntimeCore: 683ee0b6a76d4b4bf6fbf83a541895b4887cc636 React-runtimeexecutor: a188df372373baf5066e6e229177836488799f80 - React-RuntimeHermes: a264609c28b796edfffc8ae4cb8fad1773ab948b - React-runtimescheduler: 23ec3a1e0fb1ec752d1a9c1fb15258c30bfc7222 + React-RuntimeHermes: 907c8e9bec13ea6466b94828c088c24590d4d0b6 + React-runtimescheduler: a2e2a39125dd6426b5d8b773f689d660cd7c5f60 React-timing: bb220a53a795ed57976a4855c521f3de2f298fe5 - React-utils: 3b054aaebe658fc710a8d239d0e4b9fd3e0b78f9 - ReactAppDependencyProvider: a1fb08dfdc7ebc387b2e54cfc9decd283ed821d8 - ReactCodegen: 008c319179d681a6a00966edfc67fda68f9fbb2e - ReactCommon: 0c097b53f03d6bf166edbcd0915da32f3015dd90 - RNVectorIcons: bd818296a51dc2bb8c3bd97a3ca399df1afe216d + React-utils: 300d8bbb6555dcffaca71e7a0663201b5c7edbbc + ReactAppDependencyProvider: f2e81d80afd71a8058589e19d8a134243fa53f17 + ReactCodegen: 50b6e45bbbef9b39d9798820cdbe87bfc7922e22 + ReactCommon: 3d39389f8e2a2157d5c999f8fba57bd1c8f226f0 + RNVectorIcons: 3a2173c340d645b9af2dfe39801ae470a1b0a9a7 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: afd04ff05ebe0121a00c468a8a3c8080221cb14c + Yoga: 9b7fb56e7b08cde60e2153344fa6afbd88e5d99f PODFILE CHECKSUM: a15b54e8d191759ce7fcccb262b8753851ec9fde diff --git a/tools/powersynctests/ios/powersynctests.xcodeproj/project.pbxproj b/tools/powersynctests/ios/powersynctests.xcodeproj/project.pbxproj index 25f25a353..8e07244ea 100644 --- a/tools/powersynctests/ios/powersynctests.xcodeproj/project.pbxproj +++ b/tools/powersynctests/ios/powersynctests.xcodeproj/project.pbxproj @@ -380,7 +380,7 @@ "$(inherited)", " ", ); - REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; USE_HERMES = true; @@ -452,7 +452,7 @@ "$(inherited)", " ", ); - REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; VALIDATE_PRODUCT = YES;