diff --git a/.changeset/quiet-owls-flash.md b/.changeset/quiet-owls-flash.md new file mode 100644 index 000000000..1d59d5bb4 --- /dev/null +++ b/.changeset/quiet-owls-flash.md @@ -0,0 +1,20 @@ +--- +'@powersync/react-native': minor +'@powersync/common': minor +'@powersync/node': minor +'@powersync/web': minor +'@powersync/capacitor': minor +--- + +Added ability to specify `appMetadata` for sync/stream requests. + +Note: This requires a PowerSync service version `>=1.17.0` in order for logs to display metadata. + +```javascript +powerSync.connect(connector, { + // This will be included in PowerSync service logs + appMetadata: { + app_version: MY_APP_VERSION + } +}); +``` diff --git a/demos/example-node/src/main.ts b/demos/example-node/src/main.ts index f64361f0a..bfef05d51 100644 --- a/demos/example-node/src/main.ts +++ b/demos/example-node/src/main.ts @@ -10,10 +10,9 @@ import { SyncStreamConnectionMethod } from '@powersync/node'; import { exit } from 'node:process'; +import { WorkerOpener } from 'node_modules/@powersync/node/src/db/options.js'; import { AppSchema, DemoConnector } from './powersync.js'; import { enableUncidiDiagnostics } from './UndiciDiagnostics.js'; -import { WorkerOpener } from 'node_modules/@powersync/node/src/db/options.js'; -import { LockContext } from 'node_modules/@powersync/node/dist/bundle.cjs'; const main = async () => { const baseLogger = createBaseLogger(); @@ -59,10 +58,12 @@ const main = async () => { logger }); console.log(await db.get('SELECT powersync_rs_version();')); - await db.connect(new DemoConnector(), { connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET, - clientImplementation: SyncClientImplementation.RUST + clientImplementation: SyncClientImplementation.RUST, + appMetadata: { + app_version: process.env.npm_package_version || 'unknown' + } }); // Example using a proxy agent for more control over the connection: // const proxyAgent = new (await import('undici')).ProxyAgent({ diff --git a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx index 8a3f3c209..6bcf627de 100644 --- a/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist/src/components/providers/SystemProvider.tsx @@ -7,6 +7,8 @@ import { createBaseLogger, DifferentialWatchedQuery, LogLevel, PowerSyncDatabase import React, { Suspense } from 'react'; import { NavigationPanelContextProvider } from '../navigation/NavigationPanelContext'; +declare const APP_VERSION: string; + const SupabaseContext = React.createContext(null); export const useSupabase = () => React.useContext(SupabaseContext); @@ -68,7 +70,11 @@ export const SystemProvider = ({ children }: { children: React.ReactNode }) => { const l = connector.registerListener({ initialized: () => {}, sessionStarted: () => { - powerSync.connect(connector); + powerSync.connect(connector, { + appMetadata: { + app_version: APP_VERSION + } + }); } }); diff --git a/demos/react-supabase-todolist/vite.config.mts b/demos/react-supabase-todolist/vite.config.mts index ab0b7d202..ea84c4153 100644 --- a/demos/react-supabase-todolist/vite.config.mts +++ b/demos/react-supabase-todolist/vite.config.mts @@ -1,9 +1,9 @@ -import wasm from 'vite-plugin-wasm'; -import topLevelAwait from 'vite-plugin-top-level-await'; import { fileURLToPath, URL } from 'url'; +import topLevelAwait from 'vite-plugin-top-level-await'; +import wasm from 'vite-plugin-wasm'; -import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; import { VitePWA } from 'vite-plugin-pwa'; // https://vitejs.dev/config/ @@ -19,6 +19,9 @@ export default defineConfig({ resolve: { alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }] }, + define: { + APP_VERSION: JSON.stringify(process.env.npm_package_version) + }, publicDir: '../public', envDir: '..', // Use this dir for env vars, not 'src'. optimizeDeps: { diff --git a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts index ab3e37c7c..6c610f111 100644 --- a/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +++ b/packages/common/src/client/sync/stream/AbstractStreamingSyncImplementation.ts @@ -16,7 +16,7 @@ import { import { CrudEntry } from '../bucket/CrudEntry.js'; import { SyncDataBucket } from '../bucket/SyncDataBucket.js'; import { AbstractRemote, FetchStrategy, SyncStreamOptions } from './AbstractRemote.js'; -import { coreStatusToJs, EstablishSyncStream, Instruction, SyncPriorityStatus } from './core-instruction.js'; +import { EstablishSyncStream, Instruction, coreStatusToJs } from './core-instruction.js'; import { BucketRequest, CrudUploadNotification, @@ -129,6 +129,11 @@ export interface InternalConnectionOptions extends BaseConnectionOptions, Additi /** @internal */ export interface BaseConnectionOptions { + /** + * A set of metadata to be included in service logs. + */ + appMetadata?: Record; + /** * Whether to use a JavaScript implementation to handle received sync lines from the sync * service, or whether this work should be offloaded to the PowerSync core extension. @@ -223,6 +228,7 @@ export const DEFAULT_STREAMING_SYNC_OPTIONS = { export type RequiredPowerSyncConnectionOptions = Required; export const DEFAULT_STREAM_CONNECTION_OPTIONS: RequiredPowerSyncConnectionOptions = { + appMetadata: {}, connectionMethod: SyncStreamConnectionMethod.WEB_SOCKET, clientImplementation: DEFAULT_SYNC_CLIENT_IMPLEMENTATION, fetchStrategy: FetchStrategy.Buffered, @@ -658,6 +664,16 @@ The next upload iteration will be delayed.`); ...DEFAULT_STREAM_CONNECTION_OPTIONS, ...(options ?? {}) }; + + // Validate app metadata + const invalidMetadata = Object.entries(resolvedOptions.appMetadata).filter( + ([_, value]) => typeof value != 'string' + ); + if (invalidMetadata.length > 0) { + throw new Error( + `Invalid appMetadata provided. Only string values are allowed. Invalid values: ${invalidMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')}` + ); + } const clientImplementation = resolvedOptions.clientImplementation; this.updateSyncStatus({ clientImplementation }); @@ -699,6 +715,7 @@ The next upload iteration will be delayed.`); include_checksum: true, raw_data: true, parameters: resolvedOptions.params, + app_metadata: resolvedOptions.appMetadata, client_id: clientId } }; @@ -1088,6 +1105,7 @@ The next upload iteration will be delayed.`); try { const options: any = { parameters: resolvedOptions.params, + app_metadata: resolvedOptions.appMetadata, active_streams: this.activeStreams, include_defaults: resolvedOptions.includeDefaultStreams }; 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 fdb73f360..8fa10520c 100644 --- a/packages/common/src/client/sync/stream/streaming-sync-types.ts +++ b/packages/common/src/client/sync/stream/streaming-sync-types.ts @@ -90,6 +90,11 @@ export interface StreamingSyncRequest { */ parameters?: Record; + /** + * Application metadata to be included in service logs. + */ + app_metadata?: Record; + client_id?: string; } diff --git a/packages/node/tests/sync.test.ts b/packages/node/tests/sync.test.ts index 637880660..ea1feece0 100644 --- a/packages/node/tests/sync.test.ts +++ b/packages/node/tests/sync.test.ts @@ -923,6 +923,35 @@ function defineSyncTests(impl: SyncClientImplementation) { expect.arrayContaining([expect.stringContaining('Cannot enqueue data into closed stream')]) ); }); + + mockSyncServiceTest('passes app metadata to the sync service', async ({ syncService }) => { + const database = await syncService.createDatabase(); + let connectCompleted = false; + database + .connect(new TestConnector(), { + ...options, + appMetadata: { + name: 'test' + } + }) + .then(() => { + connectCompleted = true; + }); + expect(connectCompleted).toBeFalsy(); + + await vi.waitFor(() => expect(syncService.connectedListeners).toHaveLength(1)); + // We want connected: true once we have a connection + + await vi.waitFor(() => connectCompleted); + // The request should contain the app metadata + expect(syncService.connectedListeners[0]).toMatchObject( + expect.objectContaining({ + app_metadata: { + name: 'test' + } + }) + ); + }); } async function waitForProgress(