diff --git a/.changeset/mighty-keys-compete.md b/.changeset/mighty-keys-compete.md new file mode 100644 index 000000000..830e94b4c --- /dev/null +++ b/.changeset/mighty-keys-compete.md @@ -0,0 +1,6 @@ +--- +'@powersync/common': minor +'@powersync/web': minor +--- + +Add support for storage-backed (non-TEMP) SQLite triggers and tables for managed triggers. These resources persist on disk while in use and are automatically cleaned up when no longer claimed or needed. They should not be considered permanent triggers; PowerSync manages their lifecycle. diff --git a/.changeset/shaggy-donuts-boil.md b/.changeset/shaggy-donuts-boil.md new file mode 100644 index 000000000..016d54464 --- /dev/null +++ b/.changeset/shaggy-donuts-boil.md @@ -0,0 +1,5 @@ +--- +'@powersync/web': minor +--- + +Managed triggers now use storage-backed (non-TEMP) SQLite triggers and tables when OPFS is the VFS. Resources persist across tabs and connection cycles to detect cross‑tab changes, and are automatically cleaned up when no longer in use. These should not be treated as permanent triggers; their lifecycle is managed by PowerSync. diff --git a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx index 9442ed63c..d6ac28c01 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -4,7 +4,7 @@ import { SupabaseConnector } from '@/library/powersync/SupabaseConnector'; import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema'; import { CircularProgress } from '@mui/material'; import { PowerSyncContext } from '@powersync/react'; -import { LogLevel, PowerSyncDatabase, createBaseLogger } from '@powersync/web'; +import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; import { createCollection } from '@tanstack/db'; import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection'; import React, { Suspense } from 'react'; @@ -15,9 +15,10 @@ 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 + }) }); export const listsCollection = createCollection( diff --git a/packages/capacitor/src/PowerSyncDatabase.ts b/packages/capacitor/src/PowerSyncDatabase.ts index 4383cdb33..9f7fa6f93 100644 --- a/packages/capacitor/src/PowerSyncDatabase.ts +++ b/packages/capacitor/src/PowerSyncDatabase.ts @@ -1,9 +1,11 @@ import { Capacitor } from '@capacitor/core'; import { DBAdapter, + MemoryTriggerClaimManager, PowerSyncBackendConnector, RequiredAdditionalConnectionOptions, StreamingSyncImplementation, + TriggerManagerConfig, PowerSyncDatabase as WebPowerSyncDatabase, WebPowerSyncDatabaseOptionsWithSettings, WebRemote @@ -44,6 +46,18 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase { } } + protected generateTriggerManagerConfig(): TriggerManagerConfig { + const config = super.generateTriggerManagerConfig(); + if (this.isNativeCapacitorPlatform) { + /** + * We usually only ever have a single tab for capacitor. + * Avoiding navigator locks allows insecure contexts (during development). + */ + config.claimManager = new MemoryTriggerClaimManager(); + } + return config; + } + protected runExclusive(cb: () => Promise): Promise { if (this.isNativeCapacitorPlatform) { // Use mutex for mobile platforms. diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 23a6a71de..86dc83751 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -40,7 +40,8 @@ import { } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.js'; import { SyncStream } from './sync/sync-streams.js'; -import { TriggerManager } from './triggers/TriggerManager.js'; +import { MemoryTriggerClaimManager } from './triggers/MemoryTriggerClaimManager.js'; +import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js'; import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; @@ -222,6 +223,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.initialized?.()); } @@ -560,7 +574,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]); }); @@ -597,6 +610,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closing?.()); const { disconnect } = options; diff --git a/packages/common/src/client/triggers/MemoryTriggerClaimManager.ts b/packages/common/src/client/triggers/MemoryTriggerClaimManager.ts new file mode 100644 index 000000000..789dce4c2 --- /dev/null +++ b/packages/common/src/client/triggers/MemoryTriggerClaimManager.ts @@ -0,0 +1,26 @@ +import { TriggerClaimManager } from './TriggerManager.js'; + +/** + * @internal + * @experimental + */ +export class MemoryTriggerClaimManager implements TriggerClaimManager { + // Uses a global store to share the state between potentially multiple instances + private static CLAIM_STORE = new Map Promise>(); + + async obtainClaim(identifier: string): Promise<() => Promise> { + if (MemoryTriggerClaimManager.CLAIM_STORE.has(identifier)) { + throw new Error(`A claim is already present for ${identifier}`); + } + const release = async () => { + MemoryTriggerClaimManager.CLAIM_STORE.delete(identifier); + }; + MemoryTriggerClaimManager.CLAIM_STORE.set(identifier, release); + + return release; + } + + async checkClaim(identifier: string): Promise { + return MemoryTriggerClaimManager.CLAIM_STORE.has(identifier); + } +} diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 931e05eca..4d71d00b3 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -47,8 +47,9 @@ export interface BaseTriggerDiffRecord - extends BaseTriggerDiffRecord { +export interface TriggerDiffUpdateRecord< + TOperationId extends string | number = number +> extends BaseTriggerDiffRecord { operation: DiffTriggerOperation.UPDATE; /** * The updated state of the row in JSON string format. @@ -65,8 +66,9 @@ export interface TriggerDiffUpdateRecord - extends BaseTriggerDiffRecord { +export interface TriggerDiffInsertRecord< + TOperationId extends string | number = number +> extends BaseTriggerDiffRecord { operation: DiffTriggerOperation.INSERT; /** * The value of the row, at the time of INSERT, in JSON string format. @@ -79,8 +81,9 @@ export interface TriggerDiffInsertRecord - extends BaseTriggerDiffRecord { +export interface TriggerDiffDeleteRecord< + TOperationId extends string | number = number +> extends BaseTriggerDiffRecord { operation: DiffTriggerOperation.DELETE; /** * The value of the row, before the DELETE operation, in JSON string format. @@ -201,6 +204,12 @@ interface BaseCreateDiffTriggerOptions { * Hooks which allow execution during the trigger creation process. */ hooks?: TriggerCreationHooks; + + /** + * Use storage-backed (non-TEMP) tables and triggers that persist across sessions. + * These resources are still automatically disposed when no longer claimed. + */ + useStorage?: boolean; } /** @@ -449,3 +458,30 @@ export interface TriggerManager { */ trackTableDiff(options: TrackDiffOptions): Promise; } + +/** + * @experimental + * @internal + * An interface which exposes which persisted managed SQLite triggers and destination SQLite tables + * are actively in use. Resource which are not reported as claimed by this interface will be disposed. + */ + +export interface TriggerClaimManager { + /** + * Obtains or marks a claim on a certain identifier. + * @returns a callback to release the claim. + */ + obtainClaim: (identifier: string) => Promise<() => Promise>; + /** + * Checks if a claim is present for an identifier. + */ + checkClaim: (identifier: string) => Promise; +} + +/** + * @experimental + * @internal + */ +export interface TriggerManagerConfig { + claimManager: TriggerClaimManager; +} diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 0a2fcaf99..8250e7f70 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -1,24 +1,58 @@ import { LockContext } from '../../db/DBAdapter.js'; import { Schema } from '../../db/schema/Schema.js'; -import { type AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; +import type { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js'; import { CreateDiffTriggerOptions, DiffTriggerOperation, TrackDiffOptions, TriggerManager, + TriggerManagerConfig, TriggerRemoveCallback, WithDiffOptions } from './TriggerManager.js'; -export type TriggerManagerImplOptions = { +export type TriggerManagerImplOptions = TriggerManagerConfig & { db: AbstractPowerSyncDatabase; schema: Schema; }; +export type TriggerManagerImplConfiguration = { + useStorageByDefault: boolean; +}; + +export const DEFAULT_TRIGGER_MANAGER_CONFIGURATION: TriggerManagerImplConfiguration = { + useStorageByDefault: false +}; + +/** + * A record of persisted table/trigger information. + * This is used for fail-safe cleanup. + */ +type TrackedTableRecord = { + /** + * The id of the trigger. This is used in the SQLite trigger name + */ + id: string; + /** + * The destination table name for the trigger + */ + table: string; +}; + +const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes + +/** + * @internal + * @experimental + */ export class TriggerManagerImpl implements TriggerManager { protected schema: Schema; + protected defaultConfig: TriggerManagerImplConfiguration; + protected cleanupTimeout: ReturnType | null; + protected isDisposed: boolean; + constructor(protected options: TriggerManagerImplOptions) { this.schema = options.schema; options.db.registerListener({ @@ -26,6 +60,32 @@ export class TriggerManagerImpl implements TriggerManager { this.schema = schema; } }); + this.isDisposed = false; + + /** + * Configure a cleanup to run on an interval. + * The interval is configured using setTimeout to take the async + * execution time of the callback into account. + */ + this.defaultConfig = DEFAULT_TRIGGER_MANAGER_CONFIGURATION; + const cleanupCallback = async () => { + this.cleanupTimeout = null; + if (this.isDisposed) { + return; + } + try { + await this.cleanupResources(); + } catch (ex) { + this.db.logger.error(`Caught error while attempting to cleanup triggers`, ex); + } finally { + // if not closed, set another timeout + if (this.isDisposed) { + return; + } + this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS); + } + }; + this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS); } protected get db() { @@ -48,14 +108,115 @@ export class TriggerManagerImpl implements TriggerManager { } } + dispose() { + this.isDisposed = true; + if (this.cleanupTimeout) { + clearTimeout(this.cleanupTimeout); + } + } + + /** + * Updates default config settings for platform specific use-cases. + */ + updateDefaults(config: TriggerManagerImplConfiguration) { + this.defaultConfig = { + ...this.defaultConfig, + ...config + }; + } + + protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string) { + return `__ps_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`; + } + + /** + * Cleanup any SQLite triggers or tables that are no longer in use. + */ + async cleanupResources() { + // we use the database here since cleanupResources is called during the PowerSyncDatabase initialization + await this.db.database.writeLock(async (ctx) => { + // Query sqlite_master directly to find all persisted triggers and extract destination/id + // Trigger naming convention: __ps_temp_trigger_____ + // - Compute start index after the second '__' (after operation) as a CTE for clarity + // _start_index = instr(substr(name, 3), '__') + 4 + // (add 2 to account for removed leading '__', plus 2 to skip the '__' before destination) + // - Destination length excludes the trailing '__' + 36-char UUID: length(name) - _start_index - 37 + // - UUID is always last 36 chars + const trackedItems = await ctx.getAll(/* sql */ ` + WITH + trigger_names AS ( + SELECT + name, + instr (substr (name, 3), '__') + 4 AS _start_index + FROM + sqlite_master + WHERE + type = 'trigger' + AND name LIKE '__ps_temp_trigger_%' + ) + SELECT DISTINCT + substr ( + name, + _start_index, + length (name) - _start_index - 37 + ) AS "table", + substr (name, -36) AS id + FROM + trigger_names + `); + + if (trackedItems.length == 0) { + // There is nothing to cleanup + return; + } + + for (const trackedItem of trackedItems) { + // check if there is anything holding on to this item + const hasClaim = await this.options.claimManager.checkClaim(trackedItem.id); + if (hasClaim) { + // This does not require cleanup + continue; + } + + this.db.logger.debug(`Clearing resources for trigger ${trackedItem.id} with table ${trackedItem.table}`); + + // We need to delete the triggers and table + const triggerNames = Object.values(DiffTriggerOperation).map((operation) => + this.generateTriggerName(operation, trackedItem.table, trackedItem.id) + ); + for (const triggerName of triggerNames) { + // The trigger might not actually exist, we don't track each trigger name and we test all permutations + await ctx.execute(`DROP TRIGGER IF EXISTS ${triggerName}`); + } + await ctx.execute(`DROP TABLE IF EXISTS ${trackedItem.table}`); + } + }); + } + async createDiffTrigger(options: CreateDiffTriggerOptions) { await this.db.waitForReady(); - const { source, destination, columns, when, hooks } = options; + const { + source, + destination, + columns, + when, + hooks, + // Fall back to the provided default if not given on this level + useStorage = this.defaultConfig.useStorageByDefault + } = options; const operations = Object.keys(when) as DiffTriggerOperation[]; if (operations.length == 0) { throw new Error('At least one WHEN operation must be specified for the trigger.'); } + /** + * The clause to use when executing + * CREATE ${tableTriggerTypeClause} TABLE + * OR + * CREATE ${tableTriggerTypeClause} TRIGGER + */ + const tableTriggerTypeClause = !useStorage ? 'TEMP' : ''; + const whenClauses = Object.fromEntries( Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]) ); @@ -76,6 +237,8 @@ export class TriggerManagerImpl implements TriggerManager { const id = await this.getUUID(); + const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null; + /** * We default to replicating all columns if no columns array is provided. */ @@ -110,6 +273,7 @@ export class TriggerManagerImpl implements TriggerManager { return this.db.writeLock(async (tx) => { await this.removeTriggers(tx, triggerIds); await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); + await releaseStorageClaim?.(); }); }; @@ -117,22 +281,22 @@ export class TriggerManagerImpl implements TriggerManager { // Allow user code to execute in this lock context before the trigger is created. await hooks?.beforeCreate?.(tx); await tx.execute(/* sql */ ` - CREATE TEMP TABLE ${destination} ( + CREATE ${tableTriggerTypeClause} TABLE ${destination} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, operation TEXT, timestamp TEXT, value TEXT, previous_value TEXT - ); + ) `); if (operations.includes(DiffTriggerOperation.INSERT)) { - const insertTriggerId = `ps_temp_trigger_insert_${id}`; + const insertTriggerId = this.generateTriggerName(DiffTriggerOperation.INSERT, destination, id); triggerIds.push(insertTriggerId); await tx.execute(/* sql */ ` - CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[ + CREATE ${tableTriggerTypeClause} TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[ DiffTriggerOperation.INSERT ]} BEGIN INSERT INTO @@ -145,16 +309,16 @@ export class TriggerManagerImpl implements TriggerManager { ${jsonFragment('NEW')} ); - END; + END `); } if (operations.includes(DiffTriggerOperation.UPDATE)) { - const updateTriggerId = `ps_temp_trigger_update_${id}`; + const updateTriggerId = this.generateTriggerName(DiffTriggerOperation.UPDATE, destination, id); triggerIds.push(updateTriggerId); await tx.execute(/* sql */ ` - CREATE TEMP TRIGGER ${updateTriggerId} AFTER + CREATE ${tableTriggerTypeClause} TRIGGER ${updateTriggerId} AFTER UPDATE ON ${internalSource} ${whenClauses[DiffTriggerOperation.UPDATE]} BEGIN INSERT INTO ${destination} (id, operation, timestamp, value, previous_value) @@ -172,12 +336,12 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.DELETE)) { - const deleteTriggerId = `ps_temp_trigger_delete_${id}`; + const deleteTriggerId = this.generateTriggerName(DiffTriggerOperation.DELETE, destination, id); triggerIds.push(deleteTriggerId); // Create delete trigger for basic JSON await tx.execute(/* sql */ ` - CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[ + CREATE ${tableTriggerTypeClause} TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[ DiffTriggerOperation.DELETE ]} BEGIN INSERT INTO @@ -227,7 +391,7 @@ export class TriggerManagerImpl implements TriggerManager { const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name); const id = await this.getUUID(); - const destination = `ps_temp_track_${source}_${id}`; + const destination = `__ps_temp_track_${source}_${id}`; // register an onChange before the trigger is created const abortController = new AbortController(); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index bb43ea871..fbc8f333d 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -34,8 +34,10 @@ export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; export * from './client/Query.js'; +export { MemoryTriggerClaimManager } from './client/triggers/MemoryTriggerClaimManager.js'; export * from './client/triggers/sanitizeSQL.js'; export * from './client/triggers/TriggerManager.js'; +export { TriggerManagerImpl } from './client/triggers/TriggerManagerImpl.js'; export * from './client/watched/GetAllQuery.js'; export * from './client/watched/processors/AbstractQueryProcessor.js'; export * from './client/watched/processors/comparators.js'; diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index f1032a6f8..eca9c6c9e 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -704,4 +704,67 @@ describe('Triggers', () => { { timeout: 1000 } ); }); + + databaseTest('Should use persisted tables and triggers', async ({ database }) => { + const table = 'temp_remote_lists'; + + const filteredColumns: Array = ['content']; + const cleanup = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + columns: filteredColumns, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE', + [DiffTriggerOperation.UPDATE]: 'TRUE', + [DiffTriggerOperation.DELETE]: 'TRUE' + }, + useStorage: true + }); + + const results = [] as TriggerDiffRecord[]; + + database.onChange( + { + // This callback async processed. Invocations are sequential. + onChange: async () => { + await database.writeLock(async (tx) => { + const changes = await tx.getAll(/* sql */ ` + SELECT + * + FROM + ${table} + `); + results.push(...changes); + // Clear the temp table after processing + await tx.execute(/* sql */ ` DELETE FROM ${table}; `); + }); + } + }, + { tables: [table] } + ); + + // The destination table should not be temporary, it should be present in sqlite_master + const initialTableRows = await database.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [table] + ); + expect(initialTableRows.length).toEqual(1); + + await database.execute(`INSERT INTO todos (id, content) VALUES (uuid(), 'hello');`); + + // Smoke test that the trigger worked with persistence + await vi.waitFor(() => { + expect(results.length).toEqual(1); + }); + + // perform a manual cleanup + await cleanup(); + + // For sanity, the table should not exist + const tableRows = await database.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [table] + ); + expect(tableRows.length).toEqual(0); + }); }); diff --git a/packages/web/src/db/NavigatorTriggerClaimManager.ts b/packages/web/src/db/NavigatorTriggerClaimManager.ts new file mode 100644 index 000000000..65621e52b --- /dev/null +++ b/packages/web/src/db/NavigatorTriggerClaimManager.ts @@ -0,0 +1,19 @@ +import { TriggerClaimManager } from '@powersync/common'; +import { getNavigatorLocks } from '../shared/navigator'; + +export class NavigatorTriggerClaimManager implements TriggerClaimManager { + async obtainClaim(identifier: string): Promise<() => Promise> { + return new Promise((resolveReleaser) => { + getNavigatorLocks().request(identifier, async () => { + await new Promise((releaseLock) => { + resolveReleaser(async () => releaseLock()); + }); + }); + }); + } + + async checkClaim(identifier: string): Promise { + const currentState = await getNavigatorLocks().query(); + return currentState.held?.find((heldLock) => heldLock.name == identifier) != null; + } +} diff --git a/packages/web/src/db/PowerSyncDatabase.ts b/packages/web/src/db/PowerSyncDatabase.ts index fe88ac71d..223ddaa08 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -7,6 +7,7 @@ import { PowerSyncDatabaseOptionsWithSettings, SqliteBucketStorage, StreamingSyncImplementation, + TriggerManagerConfig, isDBAdapter, isSQLOpenFactory, type BucketStorageAdapter, @@ -16,6 +17,8 @@ import { } from '@powersync/common'; import { Mutex } from 'async-mutex'; import { getNavigatorLocks } from '../shared/navigator'; +import { NavigatorTriggerClaimManager } from './NavigatorTriggerClaimManager'; +import { LockedAsyncDatabaseAdapter } from './adapters/LockedAsyncDatabaseAdapter'; import { WebDBAdapter } from './adapters/WebDBAdapter'; import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; import { @@ -143,7 +146,33 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { } } - async _initialize(): Promise {} + async _initialize(): Promise { + if (this.database instanceof LockedAsyncDatabaseAdapter) { + /** + * While init is done automatically, + * LockedAsyncDatabaseAdapter only exposes config after init. + * We can explicitly wait for init here in order to access config. + */ + await this.database.init(); + } + + // In some cases, like the SQLJs adapter, we don't pass a WebDBAdapter, so we need to check. + if (typeof (this.database as WebDBAdapter).getConfiguration == 'function') { + const config = (this.database as WebDBAdapter).getConfiguration(); + if (config.requiresPersistentTriggers) { + this.triggersImpl.updateDefaults({ + useStorageByDefault: true + }); + } + } + } + + protected generateTriggerManagerConfig(): TriggerManagerConfig { + return { + // We need to share hold information between tabs for web + claimManager: new NavigatorTriggerClaimManager() + }; + } protected openDBAdapter(options: WebPowerSyncDatabaseOptionsWithSettings): DBAdapter { const defaultFactory = new WASQLiteOpenFactory({ diff --git a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts index 1a495f3c5..fba7614e4 100644 --- a/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts +++ b/packages/web/src/db/adapters/LockedAsyncDatabaseAdapter.ts @@ -13,7 +13,7 @@ import { } from '@powersync/common'; import { getNavigatorLocks } from '../../shared/navigator'; import { AsyncDatabaseConnection } from './AsyncDatabaseConnection'; -import { SharedConnectionWorker, WebDBAdapter } from './WebDBAdapter'; +import { SharedConnectionWorker, WebDBAdapter, WebDBAdapterConfiguration } from './WebDBAdapter'; import { WorkerWrappedAsyncDatabaseConnection } from './WorkerWrappedAsyncDatabaseConnection'; import { WASQLiteVFS } from './wa-sqlite/WASQLiteConnection'; import { ResolvedWASQLiteOpenFactoryOptions } from './wa-sqlite/WASQLiteOpenFactory'; @@ -181,11 +181,15 @@ export class LockedAsyncDatabaseAdapter this.iterateListeners((cb) => cb.initialized?.()); } - getConfiguration(): ResolvedWebSQLOpenOptions { + getConfiguration(): WebDBAdapterConfiguration { if (!this._config) { throw new Error(`Cannot get config before initialization is completed`); } - return this._config; + return { + ...this._config, + // This can be overridden by the adapter later + requiresPersistentTriggers: false + }; } protected async waitForInitialized() { diff --git a/packages/web/src/db/adapters/WebDBAdapter.ts b/packages/web/src/db/adapters/WebDBAdapter.ts index 2a78ca4aa..32f50b3e2 100644 --- a/packages/web/src/db/adapters/WebDBAdapter.ts +++ b/packages/web/src/db/adapters/WebDBAdapter.ts @@ -6,6 +6,10 @@ export type SharedConnectionWorker = { port: MessagePort; }; +export type WebDBAdapterConfiguration = ResolvedWebSQLOpenOptions & { + requiresPersistentTriggers: boolean; +}; + export interface WebDBAdapter extends DBAdapter { /** * Get a MessagePort which can be used to share the internals of this connection. @@ -16,5 +20,5 @@ export interface WebDBAdapter extends DBAdapter { * Get the config options used to open this connection. * This is useful for sharing connections. */ - getConfiguration(): ResolvedWebSQLOpenOptions; + getConfiguration(): WebDBAdapterConfiguration; } diff --git a/packages/web/src/db/adapters/wa-sqlite/InternalWASQLiteDBAdapter.ts b/packages/web/src/db/adapters/wa-sqlite/InternalWASQLiteDBAdapter.ts new file mode 100644 index 000000000..ba31c6193 --- /dev/null +++ b/packages/web/src/db/adapters/wa-sqlite/InternalWASQLiteDBAdapter.ts @@ -0,0 +1,23 @@ +import { LockedAsyncDatabaseAdapter } from '../LockedAsyncDatabaseAdapter'; +import { WebDBAdapterConfiguration } from '../WebDBAdapter'; +import { WASQLiteVFS } from './WASQLiteConnection'; +import { ResolvedWASQLiteOpenFactoryOptions } from './WASQLiteOpenFactory'; + +/** + * @internal + * An intermediary implementation of WASQLiteDBAdapter, which takes the same + * constructor arguments as {@link LockedAsyncDatabaseAdapter}, but provides some + * basic WA-SQLite specific functionality. + * This base class is used to avoid requiring overloading the constructor of {@link WASQLiteDBAdapter} + */ +export class InternalWASQLiteDBAdapter extends LockedAsyncDatabaseAdapter { + getConfiguration(): WebDBAdapterConfiguration { + // This is valid since we only handle WASQLite connections + const baseConfig = super.getConfiguration() as unknown as ResolvedWASQLiteOpenFactoryOptions; + return { + ...super.getConfiguration(), + requiresPersistentTriggers: + baseConfig.vfs == WASQLiteVFS.OPFSCoopSyncVFS || baseConfig.vfs == WASQLiteVFS.AccessHandlePoolVFS + }; + } +} diff --git a/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts b/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts index 1367ed811..9826b7a0a 100644 --- a/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts +++ b/packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts @@ -2,7 +2,6 @@ import { type PowerSyncOpenFactoryOptions } from '@powersync/common'; import * as Comlink from 'comlink'; import { resolveWebPowerSyncFlags } from '../../PowerSyncDatabase'; import { OpenAsyncDatabaseConnection } from '../AsyncDatabaseConnection'; -import { LockedAsyncDatabaseAdapter } from '../LockedAsyncDatabaseAdapter'; import { DEFAULT_CACHE_SIZE_KB, ResolvedWebSQLOpenOptions, @@ -10,6 +9,7 @@ import { WebSQLFlags } from '../web-sql-flags'; import { WorkerWrappedAsyncDatabaseConnection } from '../WorkerWrappedAsyncDatabaseConnection'; +import { InternalWASQLiteDBAdapter } from './InternalWASQLiteDBAdapter'; import { WASQLiteVFS } from './WASQLiteConnection'; import { WASQLiteOpenFactory } from './WASQLiteOpenFactory'; @@ -44,7 +44,7 @@ export interface WASQLiteDBAdapterOptions extends Omit this.openConnection(), debugMode: this.options.debugMode, diff --git a/packages/web/tests/bucket_storage.test.ts b/packages/web/tests/bucket_storage.test.ts index 2c173ded9..5d19b5543 100644 --- a/packages/web/tests/bucket_storage.test.ts +++ b/packages/web/tests/bucket_storage.test.ts @@ -12,7 +12,7 @@ import { } from '@powersync/common'; import { PowerSyncDatabase, WASQLitePowerSyncDatabaseOpenFactory } from '@powersync/web'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { testSchema } from './utils/testDb'; +import { TEST_SCHEMA } from './utils/test-schema'; const putAsset1_1 = OplogEntry.fromRow({ op_id: '1', @@ -69,7 +69,7 @@ describe('Bucket Storage', { sequential: true }, () => { flags: { enableMultiTabs: false }, - schema: testSchema + schema: TEST_SCHEMA }); await db.waitForReady(); bucketStorage = new SqliteBucketStorage(db.database); @@ -437,7 +437,7 @@ describe('Bucket Storage', { sequential: true }, () => { flags: { enableMultiTabs: false }, - schema: testSchema + schema: TEST_SCHEMA }); powersync = factory2.getInstance(); @@ -456,7 +456,7 @@ describe('Bucket Storage', { sequential: true }, () => { flags: { enableMultiTabs: false }, - schema: testSchema + schema: TEST_SCHEMA }); let powersync = factory.getInstance(); diff --git a/packages/web/tests/encryption.test.ts b/packages/web/tests/encryption.test.ts index 546244890..6d89837a0 100644 --- a/packages/web/tests/encryption.test.ts +++ b/packages/web/tests/encryption.test.ts @@ -7,12 +7,12 @@ import { } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { describe, expect, it } from 'vitest'; -import { testSchema } from './utils/testDb'; +import { TEST_SCHEMA } from './utils/test-schema'; describe('Encryption Tests', { sequential: true }, () => { it('IDBBatchAtomicVFS encryption', async () => { await testEncryption({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'iddb-file.db' }, encryptionKey: 'iddb-key' }); @@ -20,7 +20,7 @@ describe('Encryption Tests', { sequential: true }, () => { it('OPFSCoopSyncVFS encryption', async () => { await testEncryption({ - schema: testSchema, + schema: TEST_SCHEMA, database: new WASQLiteOpenFactory({ dbFilename: 'opfs-file.db', vfs: WASQLiteVFS.OPFSCoopSyncVFS, @@ -31,7 +31,7 @@ describe('Encryption Tests', { sequential: true }, () => { it('AccessHandlePoolVFS encryption', async () => { await testEncryption({ - schema: testSchema, + schema: TEST_SCHEMA, database: new WASQLiteOpenFactory({ dbFilename: 'ahp-file.db', vfs: WASQLiteVFS.AccessHandlePoolVFS, diff --git a/packages/web/tests/main.test.ts b/packages/web/tests/main.test.ts index 539907882..b858631b6 100644 --- a/packages/web/tests/main.test.ts +++ b/packages/web/tests/main.test.ts @@ -1,7 +1,8 @@ import { PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { describe, expect, it } from 'vitest'; -import { TestDatabase, generateTestDb, testSchema } from './utils/testDb'; +import { TEST_SCHEMA, TestDatabase } from './utils/test-schema'; +import { generateTestDb } from './utils/testDb'; // TODO import tests from a common package describe( @@ -21,7 +22,7 @@ describe( flags: { useWebWorker: false }, - schema: testSchema + schema: TEST_SCHEMA }) ) ); @@ -35,7 +36,7 @@ describe( dbFilename: 'basic-opfs.sqlite', vfs: WASQLiteVFS.OPFSCoopSyncVFS }), - schema: testSchema + schema: TEST_SCHEMA }) ) ); diff --git a/packages/web/tests/multiple_instances.test.ts b/packages/web/tests/multiple_instances.test.ts index a8a239fc4..adc665f7a 100644 --- a/packages/web/tests/multiple_instances.test.ts +++ b/packages/web/tests/multiple_instances.test.ts @@ -6,7 +6,8 @@ import { LockedAsyncDatabaseAdapter } from '../src/db/adapters/LockedAsyncDataba import { WebDBAdapter } from '../src/db/adapters/WebDBAdapter'; import { WorkerWrappedAsyncDatabaseConnection } from '../src/db/adapters/WorkerWrappedAsyncDatabaseConnection'; import { createTestConnector, sharedMockSyncServiceTest } from './utils/mockSyncServiceTest'; -import { generateTestDb, testSchema } from './utils/testDb'; +import { TEST_SCHEMA } from './utils/test-schema'; +import { generateTestDb } from './utils/testDb'; const DB_FILENAME = 'test-multiple-instances.db'; @@ -16,7 +17,7 @@ describe('Multiple Instances', { sequential: true }, () => { database: { dbFilename: DB_FILENAME }, - schema: testSchema + schema: TEST_SCHEMA }); beforeAll(() => createBaseLogger().useDefaults()); diff --git a/packages/web/tests/multiple_tabs_iframe.test.ts b/packages/web/tests/multiple_tabs_iframe.test.ts index 038425932..76eb7d487 100644 --- a/packages/web/tests/multiple_tabs_iframe.test.ts +++ b/packages/web/tests/multiple_tabs_iframe.test.ts @@ -54,45 +54,51 @@ function createIframeWithPowerSyncClient( // 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 htmlContent = /* html */ ` + + + + PowerSync Client ${identifier} + + + +
+
ID:${identifier}
+
DB:${dbFilename}
+
VFS:${vfs || 'IndexedDB (default)'}
+
+ + + `; const blob = new Blob([htmlContent], { type: 'text/html' }); const url = URL.createObjectURL(blob); diff --git a/packages/web/tests/on_change.test.ts b/packages/web/tests/on_change.test.ts index f026cde72..1b4ff4c15 100644 --- a/packages/web/tests/on_change.test.ts +++ b/packages/web/tests/on_change.test.ts @@ -2,7 +2,7 @@ import { AbstractPowerSyncDatabase, WatchOnChangeEvent } from '@powersync/common import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { testSchema } from './utils/testDb'; +import { TEST_SCHEMA } from './utils/test-schema'; const UPLOAD_TIMEOUT_MS = 3000; @@ -12,7 +12,7 @@ describe('OnChange Tests', { sequential: true }, () => { beforeEach(async () => { powersync = new PowerSyncDatabase({ database: { dbFilename: 'test-watch.db' }, - schema: testSchema, + schema: TEST_SCHEMA, flags: { enableMultiTabs: false } diff --git a/packages/web/tests/open.test.ts b/packages/web/tests/open.test.ts index 1faf76507..96f934c64 100644 --- a/packages/web/tests/open.test.ts +++ b/packages/web/tests/open.test.ts @@ -6,7 +6,7 @@ import { WASQLitePowerSyncDatabaseOpenFactory } from '@powersync/web'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { testSchema } from './utils/testDb'; +import { TEST_SCHEMA } from './utils/test-schema'; const testId = '2290de4f-0488-4e50-abed-f8e8eb1d0b42'; @@ -67,7 +67,7 @@ describe('Open Methods', { sequential: true }, () => { it('Should open PowerSync clients from old factory methods', async () => { const db = new WASQLitePowerSyncDatabaseOpenFactory({ dbFilename: `test-legacy.db`, - schema: testSchema + schema: TEST_SCHEMA }).getInstance(); await basicTest(db); @@ -75,20 +75,20 @@ describe('Open Methods', { sequential: true }, () => { it('Should open with an existing DBAdapter', async () => { const adapter = new WASQLiteDBAdapter({ dbFilename: 'adapter-test.db' }); - const db = new PowerSyncDatabase({ database: adapter, schema: testSchema }); + const db = new PowerSyncDatabase({ database: adapter, schema: TEST_SCHEMA }); await basicTest(db); }); it('Should open with provided factory', async () => { const factory = new WASQLiteOpenFactory({ dbFilename: 'factory-test.db' }); - const db = new PowerSyncDatabase({ database: factory, schema: testSchema }); + const db = new PowerSyncDatabase({ database: factory, schema: TEST_SCHEMA }); await basicTest(db); }); it('Should open with options', async () => { - const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, schema: testSchema }); + const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, schema: TEST_SCHEMA }); await basicTest(db); }); @@ -96,7 +96,7 @@ describe('Open Methods', { sequential: true }, () => { it('Should use shared worker for multiple tabs', async () => { const sharedSpy = vi.spyOn(mocks.proxies.sharedWorkerProxyHandler, 'construct'); - const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, schema: testSchema }); + const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, schema: TEST_SCHEMA }); await basicTest(db); @@ -109,7 +109,7 @@ describe('Open Methods', { sequential: true }, () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, - schema: testSchema, + schema: TEST_SCHEMA, flags: { enableMultiTabs: false } }); @@ -125,7 +125,7 @@ describe('Open Methods', { sequential: true }, () => { const db = new PowerSyncDatabase({ database: { dbFilename: 'options-test.db' }, - schema: testSchema, + schema: TEST_SCHEMA, flags: { useWebWorker: false } }); diff --git a/packages/web/tests/src/db/AbstractPowerSyncDatabase.test.ts b/packages/web/tests/src/db/AbstractPowerSyncDatabase.test.ts index 3437dea81..421568edf 100644 --- a/packages/web/tests/src/db/AbstractPowerSyncDatabase.test.ts +++ b/packages/web/tests/src/db/AbstractPowerSyncDatabase.test.ts @@ -10,7 +10,7 @@ import { StreamingSyncImplementation } from '@powersync/common'; import { describe, expect, it, vi } from 'vitest'; -import { testSchema } from '../../utils/testDb'; +import { TEST_SCHEMA } from '../../utils/test-schema'; class TestPowerSyncDatabase extends AbstractPowerSyncDatabase { protected openDBAdapter(options: PowerSyncDatabaseOptionsWithSettings): DBAdapter { @@ -45,7 +45,8 @@ class TestPowerSyncDatabase extends AbstractPowerSyncDatabase { }), getAll: vi.fn().mockResolvedValue([]), execute: vi.fn(), - refreshSchema: vi.fn() + refreshSchema: vi.fn(), + writeLock: vi.fn() } as any; } // Expose protected method for testing @@ -58,7 +59,7 @@ describe('AbstractPowerSyncDatabase', () => { describe('resolvedConnectionOptions', () => { it('should use connect options when provided', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' } }); @@ -75,7 +76,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should fallback to constructor options when connect options not provided', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, retryDelayMs: 3000, crudUploadThrottleMs: 4000 @@ -91,7 +92,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should convert retryDelay to retryDelayMs', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, retryDelay: 5000 }); @@ -106,7 +107,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should prioritize retryDelayMs over retryDelay in constructor options', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, retryDelay: 5000, retryDelayMs: 6000 @@ -122,7 +123,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should prioritize connect options over constructor options', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, retryDelayMs: 5000, crudUploadThrottleMs: 6000 @@ -141,7 +142,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should use default values when no options provided', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' } }); @@ -155,7 +156,7 @@ describe('AbstractPowerSyncDatabase', () => { it('should handle partial connect options', () => { const db = new TestPowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, retryDelayMs: 5000, crudUploadThrottleMs: 6000 diff --git a/packages/web/tests/src/db/PowersyncDatabase.test.ts b/packages/web/tests/src/db/PowersyncDatabase.test.ts index a7487ece1..d4578692e 100644 --- a/packages/web/tests/src/db/PowersyncDatabase.test.ts +++ b/packages/web/tests/src/db/PowersyncDatabase.test.ts @@ -1,6 +1,6 @@ import { createBaseLogger, PowerSyncDatabase } from '@powersync/web'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { testSchema } from '../../utils/testDb'; +import { TEST_SCHEMA } from '../../utils/test-schema'; describe('PowerSyncDatabase', () => { let db: PowerSyncDatabase; @@ -17,7 +17,7 @@ describe('PowerSyncDatabase', () => { // Initialize with minimal required options db = new PowerSyncDatabase({ - schema: testSchema, + schema: TEST_SCHEMA, database: { dbFilename: 'test.db' }, diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts new file mode 100644 index 000000000..3ed9ed770 --- /dev/null +++ b/packages/web/tests/triggers.test.ts @@ -0,0 +1,234 @@ +import { DiffTriggerOperation } from '@powersync/common'; +import { WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { TEST_SCHEMA } from './utils/test-schema'; +import { generateTestDb } from './utils/testDb'; + +// Shared helper to spin up an iframe that creates a persisted trigger table +const createTriggerInIframe = () => { + 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/triggers/IFrameTriggerConfig.ts`; + + const htmlContent = /* html */ ` + + + + PowerSync Trigger Client + + + + + `; + + const blob = new Blob([htmlContent], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const iframe = document.createElement('iframe'); + iframe.src = url; + document.body.appendChild(iframe); + return iframe; +}; + +describe('Triggers', () => { + it('should use temporary triggers by default with IndexedDB VFS', async () => { + const db = generateTestDb({ + database: new WASQLiteOpenFactory({ + dbFilename: 'temp-triggers.sqlite' + // default VFS (IndexedDB) - no vfs specified + }), + schema: TEST_SCHEMA + }); + + let _destinationName: string | null = null; + + const disposeTrigger = await db.triggers.trackTableDiff({ + source: TEST_SCHEMA.props.customers.name, + onChange: async (ctx) => { + _destinationName = ctx.destinationTable; + }, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + } + }); + + onTestFinished(disposeTrigger); + + await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); + + await vi.waitFor(() => { + expect(_destinationName).toBeTruthy(); + }); + + const destinationName = _destinationName!; + + // the table should NOT exist in `sqlite_master` since it's a TEMP table + const tableRows = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [destinationName] + ); + + expect(tableRows.length).toEqual(0); + + // but it should exist in sqlite_temp_master for TEMP tables + const tempTableRows = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_temp_master WHERE type='table' AND name = ?`, + [destinationName] + ); + + expect(tempTableRows.length).toEqual(1); + }); + + it('should automatically configure persistence for OPFS triggers', async () => { + const db = generateTestDb({ + database: new WASQLiteOpenFactory({ + dbFilename: 'triggers.sqlite', + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }), + schema: TEST_SCHEMA + }); + + let _destinationName: string | null = null; + + const disposeTrigger = await db.triggers.trackTableDiff({ + source: TEST_SCHEMA.props.customers.name, + onChange: async (ctx) => { + _destinationName = ctx.destinationTable; + }, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + } + }); + + onTestFinished(disposeTrigger); + + await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); + + await vi.waitFor(() => { + expect(_destinationName).toBeTruthy(); + }); + + const destinationName = _destinationName!; + + // the table should exist in `sqlite_master` since it's persisted + const initialTableRows = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [destinationName] + ); + + expect(initialTableRows.length).toEqual(1); + }); + + it('should cleanup persisted trigger tables when opening a new client', async () => { + const openDB = () => + generateTestDb({ + database: new WASQLiteOpenFactory({ + dbFilename: 'triggers.sqlite', + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }), + schema: TEST_SCHEMA + }); + + // An initial db for monitoring the iframes and SQLite resources + const db = openDB(); + + // allow the initial open to cleanup any previous resource from previous test runs + await vi.waitFor( + async () => { + const tables = await db.getAll<{ namce: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'xxxx_test%'` + ); + expect(tables.length).eq(0); + }, + { interval: 100 } + ); + + const firstTriggerIFrame = createTriggerInIframe(); + onTestFinished(() => firstTriggerIFrame.remove()); + + // poll for the table to exist + await vi.waitFor( + async () => { + const tables = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'xxxx_test%'` + ); + expect(tables.length).eq(1); + }, + { interval: 100 } + ); + + const secondIFrameClient = createTriggerInIframe(); + onTestFinished(() => secondIFrameClient.remove()); + + await vi.waitFor( + async () => { + const tables = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'xxxx_test%'` + ); + expect(tables.length).eq(2); + }, + { interval: 100 } + ); + + // close the first Iframe, releasing holds + firstTriggerIFrame.remove(); + + // This should clear the first resource when the new client is opened + const refreshClient = openDB(); + + await vi.waitFor( + async () => { + const tables = await db.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'xxxx_test%'` + ); + expect(tables.length).eq(1); + }, + { interval: 100 } + ); + }); + + it('should report diff operations across clients (insert from client B observed by client A)', async () => { + const openDB = (filename: string) => + generateTestDb({ + database: new WASQLiteOpenFactory({ + dbFilename: filename, + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }), + schema: TEST_SCHEMA + }); + + const filename = 'triggers-multi.sqlite'; + + const dbA = openDB(filename); + const dbB = openDB(filename); + + const observed: Array<{ id: string; op: string }> = []; + + const disposeTrigger = await dbA.triggers.trackTableDiff({ + source: TEST_SCHEMA.props.customers.name, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + }, + onChange: async (ctx) => { + const rows = await ctx.withExtractedDiff<{ id: string; __operation: string }>( + 'SELECT id, __operation FROM DIFF', + [] + ); + for (const r of rows) observed.push({ id: r.id, op: r.__operation }); + } + }); + + onTestFinished(disposeTrigger); + + // Perform an insert from client B + await dbB.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'from-client-b')"); + + // Client A should observe the diff generated by the trigger + await vi.waitFor(() => { + expect(observed.some((r) => r.op === 'INSERT')).toBe(true); + }); + }); +}); diff --git a/packages/web/tests/utils/test-schema.ts b/packages/web/tests/utils/test-schema.ts new file mode 100644 index 000000000..e97a87862 --- /dev/null +++ b/packages/web/tests/utils/test-schema.ts @@ -0,0 +1,27 @@ +import { Schema, Table, column } from '@powersync/web'; + +const assets = new Table( + { + created_at: column.text, + make: column.text, + model: column.text, + serial_number: column.text, + quantity: column.integer, + user_id: column.text, + customer_id: column.text, + description: column.text + }, + { indexes: { makemodel: ['make, model'] } } +); + +const customers = new Table({ + name: column.text, + email: column.text +}); + +export const TEST_SCHEMA = new Schema({ assets, customers }); + +export type TestDatabase = (typeof TEST_SCHEMA)['types']; + +export type Customer = TestDatabase['customers']; +export type Asset = TestDatabase['assets']; diff --git a/packages/web/tests/utils/testDb.ts b/packages/web/tests/utils/testDb.ts index 108081df8..5dbf1e40f 100644 --- a/packages/web/tests/utils/testDb.ts +++ b/packages/web/tests/utils/testDb.ts @@ -1,34 +1,14 @@ -import { column, PowerSyncDatabase, Schema, TableV2, WebPowerSyncDatabaseOptions } from '@powersync/web'; +import { PowerSyncDatabase, WebPowerSyncDatabaseOptions } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { onTestFinished } from 'vitest'; - -const assets = new TableV2( - { - created_at: column.text, - make: column.text, - model: column.text, - serial_number: column.text, - quantity: column.integer, - user_id: column.text, - customer_id: column.text, - description: column.text - }, - { indexes: { makemodel: ['make, model'] } } -); - -const customers = new TableV2({ - name: column.text, - email: column.text -}); - -export const testSchema = new Schema({ assets, customers }); +import { TEST_SCHEMA } from './test-schema'; export const generateTestDb = (options?: WebPowerSyncDatabaseOptions) => { const resolvedOptions = options ?? { database: { dbFilename: `${uuid()}.db` }, - schema: testSchema, + schema: TEST_SCHEMA, flags: { enableMultiTabs: false } @@ -46,5 +26,3 @@ export const generateTestDb = (options?: WebPowerSyncDatabaseOptions) => { return db; }; - -export type TestDatabase = (typeof testSchema)['types']; diff --git a/packages/web/tests/utils/triggers/IFrameTriggerConfig.ts b/packages/web/tests/utils/triggers/IFrameTriggerConfig.ts new file mode 100644 index 000000000..d1d838e56 --- /dev/null +++ b/packages/web/tests/utils/triggers/IFrameTriggerConfig.ts @@ -0,0 +1,26 @@ +/** + * A small script which creates a persisted trigger in an iframe. + * We'll close the iFrame in order to simulate a killed tab. + * The persisted tables should still exist after the iFrame has closed. + * We'll open another database to perform cleanup and verify the resources have been disposed. + */ + +import { DiffTriggerOperation, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; +import { TEST_SCHEMA } from '../test-schema'; + +const db = new PowerSyncDatabase({ + database: new WASQLiteOpenFactory({ + dbFilename: 'triggers.sqlite', + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }), + schema: TEST_SCHEMA +}); + +// Create the trigger, without ever performing cleanup +await db.triggers.createDiffTrigger({ + source: TEST_SCHEMA.props.customers.name, + destination: `xxxx_test_${crypto.randomUUID().replaceAll('-', '_')}`, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + } +}); diff --git a/packages/web/tests/watch.test.ts b/packages/web/tests/watch.test.ts index 9bab3798f..10c851c52 100644 --- a/packages/web/tests/watch.test.ts +++ b/packages/web/tests/watch.test.ts @@ -9,7 +9,7 @@ import { import { PowerSyncDatabase } from '@powersync/web'; import { v4 as uuid } from 'uuid'; import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { TestDatabase, testSchema } from './utils/testDb'; +import { TEST_SCHEMA, TestDatabase } from './utils/test-schema'; vi.useRealTimers(); /** @@ -28,7 +28,7 @@ describe('Watch Tests', { sequential: true }, () => { beforeEach(async () => { powersync = new PowerSyncDatabase({ database: { dbFilename: 'test-watch.db' }, - schema: testSchema, + schema: TEST_SCHEMA, flags: { enableMultiTabs: false }