diff --git a/.changeset/brown-eggs-dress.md b/.changeset/brown-eggs-dress.md new file mode 100644 index 00000000..7c649182 --- /dev/null +++ b/.changeset/brown-eggs-dress.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Improve performance of MOVE and REMOVE operations. diff --git a/.changeset/cuddly-paws-allow.md b/.changeset/cuddly-paws-allow.md new file mode 100644 index 00000000..3d1b1bd7 --- /dev/null +++ b/.changeset/cuddly-paws-allow.md @@ -0,0 +1,5 @@ +--- +'@powersync/react-native': minor +--- + +Use react-native-quick-sqlite 1.3.0 / powersync-sqlite-core 0.2.1. diff --git a/.changeset/popular-phones-knock.md b/.changeset/popular-phones-knock.md new file mode 100644 index 00000000..4a520ea8 --- /dev/null +++ b/.changeset/popular-phones-knock.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': patch +--- + +Always cast `target_op` (write checkpoint) to ensure it's an integer. diff --git a/.changeset/slow-lizards-sleep.md b/.changeset/slow-lizards-sleep.md new file mode 100644 index 00000000..42a4ef92 --- /dev/null +++ b/.changeset/slow-lizards-sleep.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Add custom x-user-agent header and client_id parameter to requests. diff --git a/.changeset/small-pants-relax.md b/.changeset/small-pants-relax.md new file mode 100644 index 00000000..5b3b04c5 --- /dev/null +++ b/.changeset/small-pants-relax.md @@ -0,0 +1,5 @@ +--- +'@powersync/web': minor +--- + +Use wa-sqlite 0.3.0 / powersync-sqlite-core 0.2.0. diff --git a/.changeset/soft-mice-type.md b/.changeset/soft-mice-type.md new file mode 100644 index 00000000..bf0ed704 --- /dev/null +++ b/.changeset/soft-mice-type.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Emit update notifications on `disconnectAndClear()`. diff --git a/.changeset/tiny-worms-mate.md b/.changeset/tiny-worms-mate.md new file mode 100644 index 00000000..70229a86 --- /dev/null +++ b/.changeset/tiny-worms-mate.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': patch +--- + +Validate that the powersync-sqlite-core version number is in a compatible range of ^0.2.0. diff --git a/.changeset/two-walls-nail.md b/.changeset/two-walls-nail.md new file mode 100644 index 00000000..658bf4f2 --- /dev/null +++ b/.changeset/two-walls-nail.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Persist lastSyncedAt timestamp. diff --git a/demos/django-react-native-todolist/package.json b/demos/django-react-native-todolist/package.json index 59396c90..20205156 100644 --- a/demos/django-react-native-todolist/package.json +++ b/demos/django-react-native-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.0", - "@journeyapps/react-native-quick-sqlite": "^1.1.7", + "@journeyapps/react-native-quick-sqlite": "^1.3.0", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-group-chat/package.json b/demos/react-native-supabase-group-chat/package.json index 8b627f32..248b6dd0 100644 --- a/demos/react-native-supabase-group-chat/package.json +++ b/demos/react-native-supabase-group-chat/package.json @@ -21,7 +21,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@faker-js/faker": "8.3.1", - "@journeyapps/react-native-quick-sqlite": "^1.1.7", + "@journeyapps/react-native-quick-sqlite": "^1.3.0", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", "@powersync/react-native": "workspace:*", diff --git a/demos/react-native-supabase-todolist/package.json b/demos/react-native-supabase-todolist/package.json index 09fb803a..05c80fed 100644 --- a/demos/react-native-supabase-todolist/package.json +++ b/demos/react-native-supabase-todolist/package.json @@ -10,7 +10,7 @@ "dependencies": { "@azure/core-asynciterator-polyfill": "^1.0.2", "@expo/vector-icons": "^14.0.0", - "@journeyapps/react-native-quick-sqlite": "^1.1.7", + "@journeyapps/react-native-quick-sqlite": "^1.3.0", "@powersync/attachments": "workspace:*", "@powersync/common": "workspace:*", "@powersync/react": "workspace:*", diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index cf512ae5..d97d9642 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -16,7 +16,6 @@ import { Schema } from '../db/schema/Schema'; import { BaseObserver } from '../utils/BaseObserver'; import { ControlledExecutor } from '../utils/ControlledExecutor'; import { mutexRunExclusive } from '../utils/mutex'; -import { quoteIdentifier } from '../utils/strings'; import { SQLOpenFactory, SQLOpenOptions, isDBAdapter, isSQLOpenFactory, isSQLOpenOptions } from './SQLOpenFactory'; import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector'; import { BucketStorageAdapter, PSInternalTable } from './sync/bucket/BucketStorageAdapter'; @@ -292,8 +291,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.initialized?.()); } + private async _loadVersion() { + try { + const { version } = await this.database.get<{ version: string }>('SELECT powersync_rs_version() as version'); + this.sdkVersion = version; + } catch (e) { + throw new Error(`The powersync extension is not loaded correctly. Details: ${e.message}`); + } + let versionInts: number[]; + try { + versionInts = this.sdkVersion!.split(/[.\/]/) + .slice(0, 3) + .map((n) => parseInt(n)); + } catch (e) { + throw new Error( + `Unsupported powersync extension version. Need ^0.2.0, got: ${this.sdkVersion}. Details: ${e.message}` + ); + } + + // Validate ^0.2.0 + if (versionInts[0] != 0 || versionInts[1] != 2 || versionInts[2] < 0) { + throw new Error(`Unsupported powersync extension version. Need ^0.2.0, got: ${this.sdkVersion}`); + } + } + protected async updateHasSynced() { - const result = await this.database.getOptional('SELECT 1 FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1'); - const hasSynced = !!result; + const result = await this.database.get<{ synced_at: string | null }>( + 'SELECT powersync_last_synced_at() as synced_at' + ); + const hasSynced = result.synced_at != null; + const syncedAt = result.synced_at != null ? new Date(result.synced_at! + 'Z') : undefined; if (hasSynced != this.currentStatus.hasSynced) { - this.currentStatus = new SyncStatus({ ...this.currentStatus.toJSON(), hasSynced }); + this.currentStatus = new SyncStatus({ ...this.currentStatus.toJSON(), hasSynced, lastSyncedAt: syncedAt }); this.iterateListeners((l) => l.statusChanged?.(this.currentStatus)); } } @@ -400,26 +425,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { - await tx.execute(`DELETE FROM ${PSInternalTable.OPLOG}`); - await tx.execute(`DELETE FROM ${PSInternalTable.CRUD}`); - await tx.execute(`DELETE FROM ${PSInternalTable.BUCKETS}`); - await tx.execute(`DELETE FROM ${PSInternalTable.UNTYPED}`); - - const tableGlob = clearLocal ? 'ps_data_*' : 'ps_data__*'; - - const existingTableRows = await tx.execute( - ` - SELECT name FROM sqlite_master WHERE type='table' AND name GLOB ? - `, - [tableGlob] - ); - - if (!existingTableRows.rows?.length) { - return; - } - for (const row of existingTableRows.rows._array) { - await tx.execute(`DELETE FROM ${quoteIdentifier(row.name)} WHERE 1`); - } + await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]); }); // The data has been deleted - reset the sync status @@ -553,6 +559,15 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { + return this.bucketStorageAdapter.getClientId(); + } + private async handleCrudCheckpoint(lastClientId: number, writeCheckpoint?: string) { return this.writeTransaction(async (tx) => { await tx.execute(`DELETE FROM ${PSInternalTable.CRUD} WHERE id <= ?`, [lastClientId]); diff --git a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts index 5cea03a2..028eb60f 100644 --- a/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts +++ b/packages/common/src/client/sync/bucket/BucketStorageAdapter.ts @@ -79,4 +79,9 @@ export interface BucketStorageAdapter extends BaseObserver; getMaxOpId(): string; + + /** + * Get an unique client id. + */ + getClientId(): Promise; } diff --git a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts index 6a4feff5..3b502358 100644 --- a/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts +++ b/packages/common/src/client/sync/bucket/SqliteBucketStorage.ts @@ -23,6 +23,7 @@ export class SqliteBucketStorage extends BaseObserver imp private pendingBucketDeletes: boolean; private _hasCompletedSync: boolean; private updateListener: () => void; + private _clientId?: Promise; /** * Count up, and do a compact on startup. @@ -62,9 +63,22 @@ export class SqliteBucketStorage extends BaseObserver imp this.updateListener?.(); } + async _getClientId() { + const row = await this.db.get<{ client_id: string }>('SELECT powersync_client_id() as client_id'); + return row['client_id']; + } + + getClientId() { + if (this._clientId == null) { + this._clientId = this._getClientId(); + } + return this._clientId!; + } + getMaxOpId() { return MAX_OP_ID; } + /** * Reset any caches. */ @@ -103,9 +117,7 @@ export class SqliteBucketStorage extends BaseObserver imp */ private async deleteBucket(bucket: string) { await this.writeTransaction(async (tx) => { - await tx.execute( - 'INSERT INTO powersync_operations(op, data) VALUES(?, ?)', - ['delete_bucket', bucket]); + await tx.execute('INSERT INTO powersync_operations(op, data) VALUES(?, ?)', ['delete_bucket', bucket]); }); this.logger.debug('done deleting bucket'); @@ -116,8 +128,8 @@ export class SqliteBucketStorage extends BaseObserver imp if (this._hasCompletedSync) { return true; } - const r = await this.db.execute(`SELECT name, last_applied_op FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1`); - const completed = !!r.rows?.length; + const r = await this.db.get<{ synced_at: string | null }>(`SELECT powersync_last_synced_at() as synced_at`); + const completed = r.synced_at != null; if (completed) { this._hasCompletedSync = true; } @@ -219,12 +231,7 @@ export class SqliteBucketStorage extends BaseObserver imp private async deletePendingBuckets() { if (this.pendingBucketDeletes !== false) { await this.writeTransaction(async (tx) => { - await tx.execute( - 'DELETE FROM ps_oplog WHERE bucket IN (SELECT name FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op)' - ); - await tx.execute( - 'DELETE FROM ps_buckets WHERE pending_delete = 1 AND last_applied_op = last_op AND last_op >= target_op' - ); + await tx.execute('INSERT INTO powersync_operations(op, data) VALUES (?, ?)', ['delete_pending_buckets', '']); }); // Executed once after start-up, and again when there are pending deletes. this.pendingBucketDeletes = false; @@ -284,7 +291,9 @@ export class SqliteBucketStorage extends BaseObserver imp return false; } - const response = await tx.execute("UPDATE ps_buckets SET target_op = ? WHERE name='$local'", [opId]); + const response = await tx.execute("UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name='$local'", [ + opId + ]); this.logger.debug(['[updateLocalTarget] Response from updating target_op ', JSON.stringify(response)]); return true; }); @@ -333,10 +342,14 @@ export class SqliteBucketStorage extends BaseObserver imp if (writeCheckpoint) { const crudResult = await tx.execute('SELECT 1 FROM ps_crud LIMIT 1'); if (crudResult.rows?.length) { - await tx.execute("UPDATE ps_buckets SET target_op = ? WHERE name='$local'", [writeCheckpoint]); + await tx.execute("UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name='$local'", [ + writeCheckpoint + ]); } } else { - await tx.execute("UPDATE ps_buckets SET target_op = ? WHERE name='$local'", [this.getMaxOpId()]); + await tx.execute("UPDATE ps_buckets SET target_op = CAST(? as INTEGER) WHERE name='$local'", [ + this.getMaxOpId() + ]); } }); } diff --git a/packages/common/src/client/sync/stream/AbstractRemote.ts b/packages/common/src/client/sync/stream/AbstractRemote.ts index fd9ba98e..730d438c 100644 --- a/packages/common/src/client/sync/stream/AbstractRemote.ts +++ b/packages/common/src/client/sync/stream/AbstractRemote.ts @@ -10,6 +10,8 @@ import type { BSON } from 'bson'; import { AbortOperation } from '../../../utils/AbortOperation'; import { Buffer } from 'buffer'; +import { version as POWERSYNC_JS_VERSION } from '../../../../package.json'; + export type BSONImplementation = typeof BSON; export type RemoteConnector = { @@ -109,6 +111,10 @@ export abstract class AbstractRemote { return this.credentials; } + getUserAgent() { + return `powersync-js/${POWERSYNC_JS_VERSION}`; + } + protected async buildRequest(path: string) { const credentials = await this.getCredentials(); if (credentials != null && (credentials.endpoint == null || credentials.endpoint == '')) { @@ -119,11 +125,14 @@ export abstract class AbstractRemote { throw error; } + const userAgent = this.getUserAgent(); + return { url: credentials.endpoint + path, headers: { 'content-type': 'application/json', - Authorization: `Token ${credentials.token}` + Authorization: `Token ${credentials.token}`, + 'x-user-agent': userAgent } }; } @@ -207,6 +216,11 @@ export abstract class AbstractRemote { const bson = await this.getBSON(); + // Add the user agent in the setup payload - we can't set custom + // headers with websockets on web. The browser userAgent is however added + // automatically as a header. + const userAgent = this.getUserAgent(); + const connector = new RSocketConnector({ transport: new WebsocketClientTransport({ url: this.options.socketUrlTransformer(request.url) @@ -220,7 +234,8 @@ export abstract class AbstractRemote { data: null, metadata: Buffer.from( bson.serialize({ - token: request.headers.Authorization + token: request.headers.Authorization, + user_agent: userAgent }) ) } diff --git a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts index 0329f29a..73cce7d1 100644 --- a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +++ b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts @@ -208,7 +208,9 @@ export abstract class AbstractStreamingSyncImplementation } async getWriteCheckpoint(): Promise { - const response = await this.options.remote.get('/write-checkpoint2.json'); + const clientId = await this.options.adapter.getClientId(); + let path = `/write-checkpoint2.json?client_id=${clientId}`; + const response = await this.options.remote.get(path); return response['data']['write_checkpoint'] as string; } @@ -456,6 +458,8 @@ The next upload iteration will be delayed.`); let bucketSet = new Set(initialBuckets.keys()); + const clientId = await this.options.adapter.getClientId(); + this.logger.debug('Requesting stream from server'); const syncOptions: SyncStreamOptions = { @@ -465,7 +469,8 @@ The next upload iteration will be delayed.`); buckets: req, include_checksum: true, raw_data: true, - parameters: resolvedOptions.params + parameters: resolvedOptions.params, + client_id: clientId } }; diff --git a/packages/common/src/client/sync/stream/streaming-sync-types.ts b/packages/common/src/client/sync/stream/streaming-sync-types.ts index fab67201..d314d912 100644 --- a/packages/common/src/client/sync/stream/streaming-sync-types.ts +++ b/packages/common/src/client/sync/stream/streaming-sync-types.ts @@ -89,6 +89,8 @@ export interface StreamingSyncRequest { * Client parameters to be passed to the sync rules. */ parameters?: Record; + + client_id?: string; } export interface StreamingSyncCheckpoint { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index d6b93809..4e55be2f 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -15,7 +15,7 @@ export * from './client/sync/bucket/OplogEntry'; export * from './client/sync/stream/AbstractRemote'; export * from './client/sync/stream/AbstractStreamingSyncImplementation'; export * from './client/sync/stream/streaming-sync-types'; -export { MAX_OP_ID } from './client/constants' +export { MAX_OP_ID } from './client/constants'; export * from './db/crud/SyncStatus'; export * from './db/crud/UploadQueueStatus'; @@ -31,7 +31,6 @@ export * from './db/schema/TableV2'; export * from './utils/AbortOperation'; export * from './utils/BaseObserver'; -export * from './utils/strings'; export * from './utils/DataStream'; export * from './utils/parseQuery'; diff --git a/packages/common/src/utils/strings.ts b/packages/common/src/utils/strings.ts deleted file mode 100644 index 057a2f6a..00000000 --- a/packages/common/src/utils/strings.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function quoteString(s: string) { - return `'${s.replaceAll("'", "''")}'`; -} - -export function quoteJsonPath(path: string) { - return quoteString(`$.${path}`); -} - -export function quoteIdentifier(s: string) { - return `"${s.replaceAll('"', '""')}"`; -} diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 66f67b71..e1c6c6ef 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -28,7 +28,7 @@ }, "homepage": "https://docs.powersync.com/", "peerDependencies": { - "@journeyapps/react-native-quick-sqlite": "^1.1.8", + "@journeyapps/react-native-quick-sqlite": "^1.3.0", "@powersync/common": "workspace:^1.16.2", "react": "*", "react-native": "*" @@ -39,7 +39,7 @@ }, "devDependencies": { "@craftzdog/react-native-buffer": "^6.0.5", - "@journeyapps/react-native-quick-sqlite": "^1.1.8", + "@journeyapps/react-native-quick-sqlite": "^1.3.0", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-inject": "^5.0.5", diff --git a/packages/react-native/src/sync/stream/ReactNativeRemote.ts b/packages/react-native/src/sync/stream/ReactNativeRemote.ts index 2c434884..2f6f8e52 100644 --- a/packages/react-native/src/sync/stream/ReactNativeRemote.ts +++ b/packages/react-native/src/sync/stream/ReactNativeRemote.ts @@ -43,6 +43,15 @@ export class ReactNativeRemote extends AbstractRemote { }); } + getUserAgent(): string { + return [ + super.getUserAgent(), + `powersync-react-native`, + `react-native/${Platform.constants.reactNativeVersion.major}.${Platform.constants.reactNativeVersion.minor}`, + `${Platform.OS}/${Platform.Version}` + ].join(' '); + } + async getBSON(): Promise { return BSON; } diff --git a/packages/web/src/db/sync/WebRemote.ts b/packages/web/src/db/sync/WebRemote.ts index 2c75e3e9..b5e8d580 100644 --- a/packages/web/src/db/sync/WebRemote.ts +++ b/packages/web/src/db/sync/WebRemote.ts @@ -10,6 +10,8 @@ import { RemoteConnector } from '@powersync/common'; +import { getUserAgentInfo } from './userAgent'; + /* * Depends on browser's implementation of global fetch. */ @@ -33,6 +35,16 @@ export class WebRemote extends AbstractRemote { }); } + getUserAgent(): string { + let ua = [super.getUserAgent(), `powersync-web`]; + try { + ua.push(...getUserAgentInfo()); + } catch (e) { + this.logger.warn('Failed to get user agent info', e); + } + return ua.join(' '); + } + async getBSON(): Promise { if (this._bson) { return this._bson; diff --git a/packages/web/src/db/sync/userAgent.ts b/packages/web/src/db/sync/userAgent.ts new file mode 100644 index 00000000..0838d868 --- /dev/null +++ b/packages/web/src/db/sync/userAgent.ts @@ -0,0 +1,77 @@ +export interface NavigatorInfo { + userAgent: string; + + userAgentData?: { + brands?: { brand: string; version: string }[]; + platform?: string; + }; +} + +/** + * Get a minimal representation of browser, version and operating system. + * + * The goal is to get enough environemnt info to reproduce issues, but no + * more. + */ +export function getUserAgentInfo(nav?: NavigatorInfo): string[] { + nav ??= navigator; + + const browser = getBrowserInfo(nav); + const os = getOsInfo(nav); + return [browser, os].filter((v) => v != null); +} + +function getBrowserInfo(nav: NavigatorInfo): string | null { + const brands = nav.userAgentData?.brands; + if (brands != null) { + const tests = [ + { name: 'Google Chrome', value: 'Chrome' }, + { name: 'Opera', value: 'Opera' }, + { name: 'Edge', value: 'Edge' }, + { name: 'Chromium', value: 'Chromium' } + ]; + for (let { name, value } of tests) { + const brand = brands.find((b) => b.brand == name); + if (brand != null) { + return `${value}/${brand.version}`; + } + } + } + + const ua = nav.userAgent; + const regexps = [ + { re: /(?:firefox|fxios)\/(\d+)/i, value: 'Firefox' }, + { re: /(?:edg|edge|edga|edgios)\/(\d+)/i, value: 'Edge' }, + { re: /opr\/(\d+)/i, value: 'Opera' }, + { re: /(?:chrome|chromium|crios)\/(\d+)/i, value: 'Chrome' }, + { re: /version\/(\d+).*safari/i, value: 'Safari' } + ]; + for (let { re, value } of regexps) { + const match = re.exec(ua); + if (match != null) { + return `${value}/${match[1]}`; + } + } + return null; +} + +function getOsInfo(nav: NavigatorInfo): string | null { + if (nav.userAgentData?.platform != null) { + return nav.userAgentData.platform.toLowerCase(); + } + + const ua = nav.userAgent; + const regexps = [ + { re: /windows/i, value: 'windows' }, + { re: /android/i, value: 'android' }, + { re: /linux/i, value: 'linux' }, + { re: /iphone|ipad|ipod/i, value: 'ios' }, + { re: /macintosh|mac os x/i, value: 'macos' } + ]; + for (let { re, value } of regexps) { + if (re.test(ua)) { + return value; + } + } + return null; +} diff --git a/packages/web/tests/bucket_storage.test.ts b/packages/web/tests/bucket_storage.test.ts index 4dda6dc8..d609fd32 100644 --- a/packages/web/tests/bucket_storage.test.ts +++ b/packages/web/tests/bucket_storage.test.ts @@ -338,8 +338,7 @@ describe('Bucket Storage', () => { OplogEntry.fromRow({ op_id: '1', op: new OpType(OpTypeEnum.MOVE).toJSON(), - checksum: 1, - data: '{"target": "3"}' + checksum: 1 }) ], false @@ -347,14 +346,6 @@ describe('Bucket Storage', () => { ]) ); - // At this point, we have target: 3, but don't have that op yet, so we cannot sync. - const result = await bucketStorage.syncLocalDatabase({ - last_op_id: '2', - buckets: [{ bucket: 'bucket1', checksum: 1 }] - }); - // Checksum passes, but we don't have a complete checkpoint - expect(result).deep.equals({ ready: false, checkpointValid: true }); - await bucketStorage.saveSyncData(new SyncDataBatch([new SyncDataBucket('bucket1', [putAsset1_3], false)])); await syncLocalChecked({ diff --git a/packages/web/tests/userAgent.test.ts b/packages/web/tests/userAgent.test.ts new file mode 100644 index 00000000..532ee7e0 --- /dev/null +++ b/packages/web/tests/userAgent.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { getUserAgentInfo } from '../src/db/sync/userAgent'; + +describe('userAgent', () => { + it('should get browser info from userAgent', function () { + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/YD2FSJ Safari/617.8' + }) + ).toEqual(['Safari/16', 'ios']); + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.99 Mobile Safari/537.36' + }) + ).toEqual(['Chrome/128', 'android']); + expect( + getUserAgentInfo({ + userAgent: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0' + }) + ).toEqual(['Firefox/130', 'linux']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' + }) + ).toEqual(['Chrome/128', 'linux']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 OPR/113.0.0.0' + }) + ).toEqual(['Opera/113', 'linux']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0(iPad; U; CPU iPhone OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B314 Safari/531.21.10' + }) + ).toEqual(['Safari/4', 'ios']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (Linux; Android 10; HD1913) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.6613.99 Mobile Safari/537.36 EdgA/127.0.2651.111' + }) + ).toEqual(['Edge/127', 'android']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/128.2739.60 Mobile/15E148 Safari/605.1.15' + }) + ).toEqual(['Edge/128', 'ios']); + + expect( + getUserAgentInfo({ + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edge/44.18363.8131' + }) + ).toEqual(['Edge/44', 'windows']); + }); + + it('should get browser info from userAgentData', function () { + expect( + getUserAgentInfo({ + userAgent: '', + userAgentData: { + platform: 'Android', + brands: [ + { + brand: 'Not)A;Brand', + version: '99' + }, + { + brand: 'Opera', + version: '113' + }, + { + brand: 'Chromium', + version: '127' + } + ] + } + }) + ).toEqual(['Opera/113', 'android']); + }); +}); diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index 1f9485c3..32cf3822 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -20,5 +20,5 @@ "path": "../common" } ], - "include": ["src/**/*", "tests/**/*"] + "include": ["src/**/*", "tests/**/*", "package.json"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09428be0..80d1f8ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ importers: specifier: ^14.0.0 version: 14.0.2 '@journeyapps/react-native-quick-sqlite': - specifier: ^1.1.7 + specifier: ^1.3.0 version: 1.3.0(react-native@0.74.5(@babel/core@7.25.2)(@babel/preset-env@7.25.4(@babel/core@7.25.2))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/common': specifier: workspace:* @@ -632,7 +632,7 @@ importers: specifier: 8.3.1 version: 8.3.1 '@journeyapps/react-native-quick-sqlite': - specifier: ^1.1.7 + specifier: ^1.3.0 version: 1.3.0(react-native@0.74.1(@babel/core@7.24.5)(@babel/preset-env@7.25.4(@babel/core@7.24.5))(@types/react@18.3.5)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/common': specifier: workspace:* @@ -768,7 +768,7 @@ importers: specifier: ^14.0.0 version: 14.0.2 '@journeyapps/react-native-quick-sqlite': - specifier: ^1.1.7 + specifier: ^1.3.0 version: 1.3.0(react-native@0.74.5(@babel/core@7.24.5)(@babel/preset-env@7.25.4(@babel/core@7.24.5))(@types/react@18.2.79)(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@powersync/attachments': specifier: workspace:* @@ -1440,7 +1440,7 @@ importers: specifier: ^6.0.5 version: 6.0.5(react-native@0.72.4(@babel/core@7.24.5)(@babel/preset-env@7.25.4(@babel/core@7.24.5))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@journeyapps/react-native-quick-sqlite': - specifier: ^1.1.8 + specifier: ^1.3.0 version: 1.3.0(react-native@0.72.4(@babel/core@7.24.5)(@babel/preset-env@7.25.4(@babel/core@7.24.5))(encoding@0.1.13)(react@18.2.0))(react@18.2.0) '@rollup/plugin-alias': specifier: ^5.1.0 diff --git a/tools/diagnostics-app/src/app/views/sync-diagnostics.tsx b/tools/diagnostics-app/src/app/views/sync-diagnostics.tsx index b628ae8d..68f24d31 100644 --- a/tools/diagnostics-app/src/app/views/sync-diagnostics.tsx +++ b/tools/diagnostics-app/src/app/views/sync-diagnostics.tsx @@ -69,14 +69,16 @@ export default function SyncDiagnosticsPage() { const [bucketRows, setBucketRows] = React.useState(null); const [tableRows, setTableRows] = React.useState(null); const [syncError, setSyncError] = React.useState(syncErrorTracker.lastSyncError); + const [lastSyncedAt, setlastSyncedAt] = React.useState(null); const bucketRowsLoading = bucketRows == null; const tableRowsLoading = tableRows == null; const refreshStats = async () => { // Similar to db.currentState.hasSynced, but synchronized to the onChange events - const hasSynced = await db.getOptional('SELECT 1 FROM ps_buckets WHERE last_applied_op > 0 LIMIT 1'); - if (hasSynced != null) { + const { synced_at } = await db.get<{ synced_at: string | null }>('SELECT powersync_last_synced_at() as synced_at'); + setlastSyncedAt(synced_at ? new Date(synced_at + 'Z') : null); + if (synced_at != null) { // These are potentially expensive queries - do not run during initial sync const bucketRows = await db.getAll(BUCKETS_QUERY); const tableRows = await db.getAll(TABLES_QUERY); @@ -207,6 +209,7 @@ export default function SyncDiagnosticsPage() { Total Data Size Total Metadata Size Total Downloaded Size + Last Synced At {totals.buckets} @@ -215,6 +218,7 @@ export default function SyncDiagnosticsPage() { {formatBytes(totals.data_size)} {formatBytes(totals.metadata_size)} {formatBytes(totals.download_size)} + {lastSyncedAt?.toLocaleTimeString()}