From 9637aaf3570baed5d6d6a878d62445ab979dcb9c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 30 Dec 2025 17:35:45 +0200 Subject: [PATCH 01/12] OPFS Multiple tab triggers --- .../components/providers/SystemProvider.tsx | 9 +- .../src/client/AbstractPowerSyncDatabase.ts | 20 ++- .../src/client/triggers/TriggerManager.ts | 37 ++++- .../src/client/triggers/TriggerManagerImpl.ts | 152 +++++++++++++++++- .../watched/MemoryTriggerHoldManager.ts | 22 +++ packages/common/src/index.ts | 1 + packages/node/tests/trigger.test.ts | 88 ++++++++++ .../web/src/db/NavigatorTriggerHoldManager.ts | 19 +++ packages/web/src/db/PowerSyncDatabase.ts | 21 ++- 9 files changed, 348 insertions(+), 21 deletions(-) create mode 100644 packages/common/src/client/watched/MemoryTriggerHoldManager.ts create mode 100644 packages/web/src/db/NavigatorTriggerHoldManager.ts 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..6a52dbf45 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: 'test2.sqlite', + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }) }); export const listsCollection = createCollection( diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 23a6a71de..9e02c6c5f 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -42,6 +42,7 @@ import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.j import { SyncStream } from './sync/sync-streams.js'; import { TriggerManager } from './triggers/TriggerManager.js'; import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; +import { MemoryTriggerHoldManager } from './watched/MemoryTriggerHoldManager.js'; import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; import { WatchedQueryComparator } from './watched/processors/comparators.js'; @@ -222,6 +223,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.initialized?.()); } diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 931e05eca..153249993 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; + + /** + * Using persistence will result in creating a persisted SQLite trigger + * and destination table. + */ + usePersistence?: boolean; } /** @@ -449,3 +458,19 @@ export interface TriggerManager { */ trackTableDiff(options: TrackDiffOptions): Promise; } + +/** + * @experimental + */ + +export interface TriggerHoldManager { + /** + * Obtains or marks a hold on a certain identifier. + * @returns a callback to release the hold. + */ + obtainHold: (identifier: string) => Promise<() => Promise>; + /** + * Checks if a hold is present for an identifier. + */ + checkHold: (identifier: string) => Promise; +} diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 0a2fcaf99..d53fe237d 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -6,6 +6,7 @@ import { CreateDiffTriggerOptions, DiffTriggerOperation, TrackDiffOptions, + TriggerHoldManager, TriggerManager, TriggerRemoveCallback, WithDiffOptions @@ -14,8 +15,31 @@ import { export type TriggerManagerImplOptions = { db: AbstractPowerSyncDatabase; schema: Schema; + holdManager: TriggerHoldManager; + usePersistenceByDefault?: () => Promise; }; +/** + * 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_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; + +/** + * @internal + * @experimental + */ export class TriggerManagerImpl implements TriggerManager { protected schema: Schema; @@ -48,14 +72,74 @@ export class TriggerManagerImpl implements TriggerManager { } } + async cleanupStaleItems() { + await this.db.writeLock(async (ctx) => { + const storedRecords = await ctx.getOptional<{ value: string }>( + /* sql */ ` + SELECT + value + FROM + ps_kv + WHERE + key = ? + `, + [TRIGGER_TABLE_TRACKING_KEY] + ); + if (!storedRecords) { + // There is nothing to cleanup + return; + } + const trackedItems = JSON.parse(storedRecords.value) as TrackedTableRecord[]; + 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 hasHold = await this.options.holdManager.checkHold(trackedItem.id); + if (hasHold) { + // This does not require cleanup + continue; + } + + // We need to delete the table and triggers + const triggerNames = Object.values(DiffTriggerOperation).map( + (value) => `ps_temp_trigger_${value.toLowerCase()}_${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 + usePersistence = await this.options.usePersistenceByDefault?.() + } = 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 = !usePersistence ? 'TEMP' : ''; + const whenClauses = Object.fromEntries( Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]) ); @@ -76,6 +160,8 @@ export class TriggerManagerImpl implements TriggerManager { const id = await this.getUUID(); + const releasePersistenceHold = usePersistence ? await this.options.holdManager.obtainHold(id) : null; + /** * We default to replicating all columns if no columns array is provided. */ @@ -110,6 +196,39 @@ 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};`); + if (usePersistence) { + // Remove these triggers and tables from the list of items to safeguard cleanup for. + await tx.execute( + /* sql */ ` + UPDATE ps_kv + SET + value = ( + SELECT + json_group_array (json_each.value) + FROM + json_each (value) + WHERE + json_extract (json_each.value, '$.id') != ? + ) + WHERE + key = ?; + `, + [id, TRIGGER_TABLE_TRACKING_KEY] + ); + + // Remove the key when the array becomes empty + await tx.execute( + /* sql */ ` + DELETE FROM ps_kv + WHERE + key = ? + AND value IS NULL; + `, + [TRIGGER_TABLE_TRACKING_KEY] + ); + } + + await releasePersistenceHold?.(); }); }; @@ -117,7 +236,7 @@ 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, @@ -127,12 +246,35 @@ export class TriggerManagerImpl implements TriggerManager { ); `); + if (usePersistence) { + /** + * Register the table for cleanup management + * Store objects of the form { id: string, table: string } in the JSON array. + */ + await tx.execute( + /* sql */ ` + INSERT INTO + ps_kv (key, value) + VALUES + (?, json_array (json_object ('id', ?, 'table', ?))) ON CONFLICT (key) DO + UPDATE + SET + value = json_insert ( + value, + '$[' || json_array_length (value) || ']', + json_object ('id', ?, 'table', ?) + ); + `, + [TRIGGER_TABLE_TRACKING_KEY, id, destination, id, destination] + ); + } + if (operations.includes(DiffTriggerOperation.INSERT)) { const insertTriggerId = `ps_temp_trigger_insert_${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 @@ -154,7 +296,7 @@ export class TriggerManagerImpl implements TriggerManager { 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) @@ -177,7 +319,7 @@ export class TriggerManagerImpl implements TriggerManager { // 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 diff --git a/packages/common/src/client/watched/MemoryTriggerHoldManager.ts b/packages/common/src/client/watched/MemoryTriggerHoldManager.ts new file mode 100644 index 000000000..90ccc6d2d --- /dev/null +++ b/packages/common/src/client/watched/MemoryTriggerHoldManager.ts @@ -0,0 +1,22 @@ +import { TriggerHoldManager } from '../triggers/TriggerManager.js'; + +export class MemoryTriggerHoldManager implements TriggerHoldManager { + // Uses a global store to share the state between potentially multiple instances + private static HOLD_STORE = new Map Promise>(); + + async obtainHold(identifier: string): Promise<() => Promise> { + if (MemoryTriggerHoldManager.HOLD_STORE.has(identifier)) { + throw new Error(`A hold is already present for ${identifier}`); + } + const release = async () => { + MemoryTriggerHoldManager.HOLD_STORE.delete(identifier); + }; + MemoryTriggerHoldManager.HOLD_STORE.set(identifier, release); + + return release; + } + + async checkHold(identifier: string): Promise { + return MemoryTriggerHoldManager.HOLD_STORE.has(identifier); + } +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7173a2ece..8becaeaa9 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -35,6 +35,7 @@ export * from './db/schema/TableV2.js'; export * from './client/Query.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..6b366f3ca 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -704,4 +704,92 @@ 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' + }, + usePersistence: 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); + }); + + const getTrackedTables = async () => { + // The table should be tracked in ps_kv + const { value: trackedTableItemsString } = await database.get<{ value: string }>( + 'SELECT value from ps_kv where key = ?', + ['powersync_tables_to_cleanup'] + ); + + type TrackedTableRecord = { + id: string; + table: string; + }; + + const trackedTableItems: TrackedTableRecord[] = JSON.parse(trackedTableItemsString); + return trackedTableItems; + }; + + const initialTrackedTableItems = await getTrackedTables(); + // There should be an entry for this item + expect(initialTrackedTableItems.find((item) => item.table == table)).toBeDefined(); + + // perform a manual cleanup + await cleanup(); + + const afterCleanupTrackedTableItems = await getTrackedTables(); + + // There should no longer be an entry for this, since we ran cleanup + expect(afterCleanupTrackedTableItems.find((item) => item.table == table)).toBeUndefined(); + + // 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/NavigatorTriggerHoldManager.ts b/packages/web/src/db/NavigatorTriggerHoldManager.ts new file mode 100644 index 000000000..314ca0a4e --- /dev/null +++ b/packages/web/src/db/NavigatorTriggerHoldManager.ts @@ -0,0 +1,19 @@ +import { TriggerHoldManager } from '@powersync/common'; +import { getNavigatorLocks } from '../shared/navigator'; + +export class NavigatorTriggerHoldManager implements TriggerHoldManager { + async obtainHold(identifier: string): Promise<() => Promise> { + return new Promise((resolveReleaser) => { + getNavigatorLocks().request(identifier, async () => { + await new Promise((releaseLock) => { + resolveReleaser(async () => releaseLock()); + }); + }); + }); + } + + async checkHold(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 0e60de018..a6cca1207 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -13,11 +13,13 @@ import { PowerSyncDatabaseOptionsWithOpenFactory, PowerSyncDatabaseOptionsWithSettings, SqliteBucketStorage, - StreamingSyncImplementation + StreamingSyncImplementation, + TriggerManagerImpl } from '@powersync/common'; import { Mutex } from 'async-mutex'; import { getNavigatorLocks } from '../shared/navigator'; -import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; +import { WASQLiteVFS } from './adapters/wa-sqlite/WASQLiteConnection'; +import { ResolvedWASQLiteOpenFactoryOptions, WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; import { DEFAULT_WEB_SQL_FLAGS, ResolvedWebSQLOpenOptions, @@ -25,6 +27,7 @@ import { WebSQLFlags } from './adapters/web-sql-flags'; import { WebDBAdapter } from './adapters/WebDBAdapter'; +import { NavigatorTriggerHoldManager } from './NavigatorTriggerHoldManager'; import { SharedWebStreamingSyncImplementation } from './sync/SharedWebStreamingSyncImplementation'; import { SSRStreamingSyncImplementation } from './sync/SSRWebStreamingSyncImplementation'; import { WebRemote } from './sync/WebRemote'; @@ -146,6 +149,20 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { async _initialize(): Promise {} + protected generateTriggerManager(): TriggerManagerImpl { + // TODO, capacitor should not use navigator locks + return new TriggerManagerImpl({ + db: this, + schema: this.schema, + // We need to share hold information between tabs + holdManager: new NavigatorTriggerHoldManager(), + // TODO, cleanup + usePersistenceByDefault: async () => + ((this.database as WebDBAdapter).getConfiguration() as ResolvedWASQLiteOpenFactoryOptions).vfs != + WASQLiteVFS.IDBBatchAtomicVFS + }); + } + protected openDBAdapter(options: WebPowerSyncDatabaseOptionsWithSettings): DBAdapter { const defaultFactory = new WASQLiteOpenFactory({ ...options.database, From 22877b6dc84c75a0e42009b9726ec2a1dafeca73 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 6 Jan 2026 17:18:27 +0200 Subject: [PATCH 02/12] cleanup defaults and typing --- packages/capacitor/src/PowerSyncDatabase.ts | 14 +++++++ .../src/client/AbstractPowerSyncDatabase.ts | 21 ++++++---- .../MemoryTriggerHoldManager.ts | 6 ++- .../src/client/triggers/TriggerManager.ts | 11 +++++ .../src/client/triggers/TriggerManagerImpl.ts | 31 +++++++++++--- packages/common/src/index.ts | 1 + packages/web/src/db/PowerSyncDatabase.ts | 42 +++++++++++-------- .../db/adapters/LockedAsyncDatabaseAdapter.ts | 10 +++-- packages/web/src/db/adapters/WebDBAdapter.ts | 6 ++- .../wa-sqlite/InternalWASQLiteDBAdapter.ts | 23 ++++++++++ .../adapters/wa-sqlite/WASQLiteDBAdapter.ts | 4 +- .../adapters/wa-sqlite/WASQLiteOpenFactory.ts | 4 +- 12 files changed, 132 insertions(+), 41 deletions(-) rename packages/common/src/client/{watched => triggers}/MemoryTriggerHoldManager.ts (88%) create mode 100644 packages/web/src/db/adapters/wa-sqlite/InternalWASQLiteDBAdapter.ts diff --git a/packages/capacitor/src/PowerSyncDatabase.ts b/packages/capacitor/src/PowerSyncDatabase.ts index 4383cdb33..20ecf036c 100644 --- a/packages/capacitor/src/PowerSyncDatabase.ts +++ b/packages/capacitor/src/PowerSyncDatabase.ts @@ -1,9 +1,11 @@ import { Capacitor } from '@capacitor/core'; import { DBAdapter, + MemoryTriggerHoldManager, 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.holdManager = new MemoryTriggerHoldManager(); + } + 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 9e02c6c5f..e6a004c35 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -40,9 +40,9 @@ 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 { MemoryTriggerHoldManager } from './triggers/MemoryTriggerHoldManager.js'; +import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js'; import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; -import { MemoryTriggerHoldManager } from './watched/MemoryTriggerHoldManager.js'; import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; import { WatchedQueryComparator } from './watched/processors/comparators.js'; @@ -298,7 +298,11 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver Promise>(); diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 153249993..37e658964 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -461,6 +461,9 @@ export interface TriggerManager { /** * @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 TriggerHoldManager { @@ -474,3 +477,11 @@ export interface TriggerHoldManager { */ checkHold: (identifier: string) => Promise; } + +/** + * @experimental + * @internal + */ +export interface TriggerManagerConfig { + holdManager: TriggerHoldManager; +} diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index d53fe237d..070575df3 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -1,22 +1,28 @@ 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, - TriggerHoldManager, TriggerManager, + TriggerManagerConfig, TriggerRemoveCallback, WithDiffOptions } from './TriggerManager.js'; -export type TriggerManagerImplOptions = { +export type TriggerManagerImplOptions = TriggerManagerConfig & { db: AbstractPowerSyncDatabase; schema: Schema; - holdManager: TriggerHoldManager; - usePersistenceByDefault?: () => Promise; +}; + +export type TriggerManagerImplConfiguration = { + usePersistenceByDefault: boolean; +}; + +export const DEFAULT_TRIGGER_MANAGER_CONFIGURATION: TriggerManagerImplConfiguration = { + usePersistenceByDefault: false }; /** @@ -43,6 +49,8 @@ const TRIGGER_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; export class TriggerManagerImpl implements TriggerManager { protected schema: Schema; + protected defaultConfig: TriggerManagerImplConfiguration; + constructor(protected options: TriggerManagerImplOptions) { this.schema = options.schema; options.db.registerListener({ @@ -50,6 +58,7 @@ export class TriggerManagerImpl implements TriggerManager { this.schema = schema; } }); + this.defaultConfig = DEFAULT_TRIGGER_MANAGER_CONFIGURATION; } protected get db() { @@ -72,6 +81,16 @@ export class TriggerManagerImpl implements TriggerManager { } } + /** + * Updates default config settings for platform specific use-cases. + */ + updateDefaults(config: TriggerManagerImplConfiguration) { + this.defaultConfig = { + ...this.defaultConfig, + ...config + }; + } + async cleanupStaleItems() { await this.db.writeLock(async (ctx) => { const storedRecords = await ctx.getOptional<{ value: string }>( @@ -125,7 +144,7 @@ export class TriggerManagerImpl implements TriggerManager { when, hooks, // Fall back to the provided default if not given on this level - usePersistence = await this.options.usePersistenceByDefault?.() + usePersistence = this.defaultConfig.usePersistenceByDefault } = options; const operations = Object.keys(when) as DiffTriggerOperation[]; if (operations.length == 0) { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index b0ab9e9f3..5fb117415 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -34,6 +34,7 @@ export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; export * from './client/Query.js'; +export { MemoryTriggerHoldManager } from './client/triggers/MemoryTriggerHoldManager.js'; export * from './client/triggers/sanitizeSQL.js'; export * from './client/triggers/TriggerManager.js'; export { TriggerManagerImpl } from './client/triggers/TriggerManagerImpl.js'; diff --git a/packages/web/src/db/PowerSyncDatabase.ts b/packages/web/src/db/PowerSyncDatabase.ts index 13544d187..3a2514363 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -7,7 +7,7 @@ import { PowerSyncDatabaseOptionsWithSettings, SqliteBucketStorage, StreamingSyncImplementation, - TriggerManagerImpl, + TriggerManagerConfig, isDBAdapter, isSQLOpenFactory, type BucketStorageAdapter, @@ -18,9 +18,9 @@ import { import { Mutex } from 'async-mutex'; import { getNavigatorLocks } from '../shared/navigator'; import { NavigatorTriggerHoldManager } from './NavigatorTriggerHoldManager'; +import { LockedAsyncDatabaseAdapter } from './adapters/LockedAsyncDatabaseAdapter'; import { WebDBAdapter } from './adapters/WebDBAdapter'; -import { WASQLiteVFS } from './adapters/wa-sqlite/WASQLiteConnection'; -import { ResolvedWASQLiteOpenFactoryOptions, WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; +import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; import { DEFAULT_WEB_SQL_FLAGS, ResolvedWebSQLOpenOptions, @@ -146,20 +146,28 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { } } - async _initialize(): Promise {} - - protected generateTriggerManager(): TriggerManagerImpl { - // TODO, capacitor should not use navigator locks - return new TriggerManagerImpl({ - db: this, - schema: this.schema, - // We need to share hold information between tabs - holdManager: new NavigatorTriggerHoldManager(), - // TODO, cleanup - usePersistenceByDefault: async () => - ((this.database as WebDBAdapter).getConfiguration() as ResolvedWASQLiteOpenFactoryOptions).vfs != - WASQLiteVFS.IDBBatchAtomicVFS - }); + 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(); + } + const config = (this.database as WebDBAdapter).getConfiguration(); + if (config.requiresPersistentTriggers) { + this.triggersImpl.updateDefaults({ + usePersistenceByDefault: true + }); + } + } + + protected generateTriggerManagerConfig(): TriggerManagerConfig { + return { + // We need to share hold information between tabs for web + holdManager: new NavigatorTriggerHoldManager() + }; } protected openDBAdapter(options: WebPowerSyncDatabaseOptionsWithSettings): DBAdapter { 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, From fcfcc32a11711e85a92b13946b3ffa0890831e64 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 7 Jan 2026 11:27:06 +0200 Subject: [PATCH 03/12] Improve disposal of resources. Add unit tests for disposal. --- .../src/client/AbstractPowerSyncDatabase.ts | 20 +- .../src/client/triggers/TriggerManagerImpl.ts | 68 ++++--- packages/web/tests/bucket_storage.test.ts | 8 +- packages/web/tests/encryption.test.ts | 8 +- packages/web/tests/main.test.ts | 7 +- packages/web/tests/multiple_instances.test.ts | 5 +- .../web/tests/multiple_tabs_iframe.test.ts | 84 +++++---- packages/web/tests/on_change.test.ts | 4 +- packages/web/tests/open.test.ts | 16 +- .../src/db/AbstractPowerSyncDatabase.test.ts | 19 +- .../tests/src/db/PowersyncDatabase.test.ts | 4 +- packages/web/tests/triggers.test.ts | 174 ++++++++++++++++++ packages/web/tests/utils/test-schema.ts | 27 +++ packages/web/tests/utils/testDb.ts | 28 +-- .../utils/triggers/IFrameTriggerConfig.ts | 26 +++ packages/web/tests/watch.test.ts | 4 +- 16 files changed, 363 insertions(+), 139 deletions(-) create mode 100644 packages/web/tests/triggers.test.ts create mode 100644 packages/web/tests/utils/test-schema.ts create mode 100644 packages/web/tests/utils/triggers/IFrameTriggerConfig.ts diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index e6a004c35..005ae9ddc 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -42,7 +42,7 @@ import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.j import { SyncStream } from './sync/sync-streams.js'; import { MemoryTriggerHoldManager } from './triggers/MemoryTriggerHoldManager.js'; import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js'; -import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; +import { TRIGGER_TABLE_TRACKING_KEY, TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; import { WatchedQueryComparator } from './watched/processors/comparators.js'; @@ -433,8 +433,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.initialized?.()); } @@ -575,9 +574,22 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { + /** + * Hack!: powersync_clear will keys != 'client_id' in the ps_kv table. + * We currently use ps_kv to track the tracked persisted tables and triggers. + * We can fetch and restore the key/value here. + */ + const result = await tx.getOptional<{ value: string }>('SELECT value FROM ps_kv WHERE key = ?', [ + TRIGGER_TABLE_TRACKING_KEY + ]); await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]); + if (result) { + await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?)', [ + TRIGGER_TABLE_TRACKING_KEY, + result.value + ]); + } }); // The data has been deleted - reset the sync status diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 070575df3..970439ee9 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -40,7 +40,7 @@ type TrackedTableRecord = { table: string; }; -const TRIGGER_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; +export const TRIGGER_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; /** * @internal @@ -91,8 +91,12 @@ export class TriggerManagerImpl implements TriggerManager { }; } - async cleanupStaleItems() { - await this.db.writeLock(async (ctx) => { + /** + * 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) => { const storedRecords = await ctx.getOptional<{ value: string }>( /* sql */ ` SELECT @@ -131,6 +135,9 @@ export class TriggerManagerImpl implements TriggerManager { await ctx.execute(`DROP TRIGGER IF EXISTS ${triggerName}`); } await ctx.execute(`DROP TABLE IF EXISTS ${trackedItem.table}`); + + // Also remove the tracked record for this. + await this.removeTriggerRecord(ctx, trackedItem); } }); } @@ -216,37 +223,8 @@ export class TriggerManagerImpl implements TriggerManager { await this.removeTriggers(tx, triggerIds); await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); if (usePersistence) { - // Remove these triggers and tables from the list of items to safeguard cleanup for. - await tx.execute( - /* sql */ ` - UPDATE ps_kv - SET - value = ( - SELECT - json_group_array (json_each.value) - FROM - json_each (value) - WHERE - json_extract (json_each.value, '$.id') != ? - ) - WHERE - key = ?; - `, - [id, TRIGGER_TABLE_TRACKING_KEY] - ); - - // Remove the key when the array becomes empty - await tx.execute( - /* sql */ ` - DELETE FROM ps_kv - WHERE - key = ? - AND value IS NULL; - `, - [TRIGGER_TABLE_TRACKING_KEY] - ); + await this.removeTriggerRecord(tx, { id, table: destination }); } - await releasePersistenceHold?.(); }); }; @@ -279,8 +257,8 @@ export class TriggerManagerImpl implements TriggerManager { UPDATE SET value = json_insert ( - value, - '$[' || json_array_length (value) || ']', + ps_kv.value, + '$[' || json_array_length (ps_kv.value) || ']', json_object ('id', ?, 'table', ?) ); `, @@ -478,4 +456,24 @@ export class TriggerManagerImpl implements TriggerManager { throw error; } } + + protected async removeTriggerRecord(ctx: LockContext, record: TrackedTableRecord) { + await ctx.execute( + /* sql */ ` + UPDATE ps_kv AS kv + SET + value = ( + SELECT + json_group_array (json_each.value) + FROM + json_each (kv.value) -- Explicitly reference kv.value + WHERE + json_extract (json_each.value, '$.id') != ? + ) + WHERE + key = ?; + `, + [record.id, TRIGGER_TABLE_TRACKING_KEY] + ); + } } 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..c425acaea --- /dev/null +++ b/packages/web/tests/triggers.test.ts @@ -0,0 +1,174 @@ +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'; + +describe('Triggers', () => { + 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', { timeout: Infinity }, async () => { + debugger; + /** + * Creates a simple iFrame which loads a script which creates a managed trigger. + */ + 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; + }; + + 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 remember table state after disconnectAndClear', async () => { + const db = generateTestDb({ + database: new WASQLiteOpenFactory({ + dbFilename: 'triggers.sqlite', + vfs: WASQLiteVFS.OPFSCoopSyncVFS + }), + schema: TEST_SCHEMA + }); + + await db.triggers.createDiffTrigger({ + source: TEST_SCHEMA.props.customers.name, + destination: 'xxxx_test_1', + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + } + }); + + await db.disconnectAndClear(); + + const state = await db.get<{ value: string }>('SELECT value FROM ps_kv WHERE key = ?', [ + 'powersync_tables_to_cleanup' + ]); + expect(state).toBeDefined(); + expect(state?.value).toBeDefined(); + expect(JSON.parse(state?.value ?? '[]').length).eq(1); + }); +}); 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 } From 24ac097e95986778b1a74157e715cca2db023bc4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 11:10:34 +0200 Subject: [PATCH 04/12] use trigger name to determine the table name. cleanup resources with a timeout. --- .../src/client/AbstractPowerSyncDatabase.ts | 18 +-- .../src/client/triggers/TriggerManagerImpl.ts | 137 +++++++++--------- packages/web/tests/triggers.test.ts | 27 ---- 3 files changed, 68 insertions(+), 114 deletions(-) diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 005ae9ddc..85ad9bd51 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -42,7 +42,7 @@ import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.j import { SyncStream } from './sync/sync-streams.js'; import { MemoryTriggerHoldManager } from './triggers/MemoryTriggerHoldManager.js'; import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js'; -import { TRIGGER_TABLE_TRACKING_KEY, TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; +import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js'; import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js'; import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js'; import { WatchedQueryComparator } from './watched/processors/comparators.js'; @@ -575,21 +575,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver { - /** - * Hack!: powersync_clear will keys != 'client_id' in the ps_kv table. - * We currently use ps_kv to track the tracked persisted tables and triggers. - * We can fetch and restore the key/value here. - */ - const result = await tx.getOptional<{ value: string }>('SELECT value FROM ps_kv WHERE key = ?', [ - TRIGGER_TABLE_TRACKING_KEY - ]); await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]); - if (result) { - await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?)', [ - TRIGGER_TABLE_TRACKING_KEY, - result.value - ]); - } }); // The data has been deleted - reset the sync status @@ -624,6 +610,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver cb.closing?.()); const { disconnect } = options; diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 970439ee9..6bc5a12c3 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -42,6 +42,8 @@ type TrackedTableRecord = { export const TRIGGER_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; +const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes + /** * @internal * @experimental @@ -50,6 +52,8 @@ 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; @@ -58,7 +62,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() { @@ -81,6 +110,13 @@ export class TriggerManagerImpl implements TriggerManager { } } + dispose() { + this.isDisposed = true; + if (this.cleanupTimeout) { + clearTimeout(this.cleanupTimeout); + } + } + /** * Updates default config settings for platform specific use-cases. */ @@ -97,22 +133,26 @@ export class TriggerManagerImpl implements TriggerManager { async cleanupResources() { // we use the database here since cleanupResources is called during the PowerSyncDatabase initialization await this.db.database.writeLock(async (ctx) => { - const storedRecords = await ctx.getOptional<{ value: string }>( - /* sql */ ` - SELECT - value - FROM - ps_kv - WHERE - key = ? - `, - [TRIGGER_TABLE_TRACKING_KEY] - ); - if (!storedRecords) { - // There is nothing to cleanup - return; - } - const trackedItems = JSON.parse(storedRecords.value) as TrackedTableRecord[]; + // Query sqlite_master directly to find all persisted triggers and extract destination/id + // Trigger naming convention: ps_temp_trigger_____ + // - Start after first '__': instr(name, '__') + 2 + // - Length: total - start_offset - separator(2) - uuid(36) = length(name) - instr(name, '__') - 39 + // - UUID is always last 36 chars + const trackedItems = await ctx.getAll(/* sql */ ` + SELECT DISTINCT + substr ( + name, + instr (name, '__') + 2, + length (name) - instr (name, '__') - 39 + ) as "table", + substr (name, -36) as id + FROM + sqlite_master + WHERE + type = 'trigger' + AND name LIKE 'ps_temp_trigger_%' + `); + if (trackedItems.length == 0) { // There is nothing to cleanup return; @@ -126,18 +166,17 @@ export class TriggerManagerImpl implements TriggerManager { continue; } - // We need to delete the table and triggers + 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( - (value) => `ps_temp_trigger_${value.toLowerCase()}_${trackedItem.id}` + (value) => `ps_temp_trigger_${value.toLowerCase()}__${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}`); - - // Also remove the tracked record for this. - await this.removeTriggerRecord(ctx, trackedItem); } }); } @@ -222,9 +261,6 @@ 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};`); - if (usePersistence) { - await this.removeTriggerRecord(tx, { id, table: destination }); - } await releasePersistenceHold?.(); }); }; @@ -240,34 +276,11 @@ export class TriggerManagerImpl implements TriggerManager { timestamp TEXT, value TEXT, previous_value TEXT - ); + ) `); - if (usePersistence) { - /** - * Register the table for cleanup management - * Store objects of the form { id: string, table: string } in the JSON array. - */ - await tx.execute( - /* sql */ ` - INSERT INTO - ps_kv (key, value) - VALUES - (?, json_array (json_object ('id', ?, 'table', ?))) ON CONFLICT (key) DO - UPDATE - SET - value = json_insert ( - ps_kv.value, - '$[' || json_array_length (ps_kv.value) || ']', - json_object ('id', ?, 'table', ?) - ); - `, - [TRIGGER_TABLE_TRACKING_KEY, id, destination, id, destination] - ); - } - if (operations.includes(DiffTriggerOperation.INSERT)) { - const insertTriggerId = `ps_temp_trigger_insert_${id}`; + const insertTriggerId = `ps_temp_trigger_insert__${destination}__${id}`; triggerIds.push(insertTriggerId); await tx.execute(/* sql */ ` @@ -284,12 +297,12 @@ export class TriggerManagerImpl implements TriggerManager { ${jsonFragment('NEW')} ); - END; + END `); } if (operations.includes(DiffTriggerOperation.UPDATE)) { - const updateTriggerId = `ps_temp_trigger_update_${id}`; + const updateTriggerId = `ps_temp_trigger_update__${destination}__${id}`; triggerIds.push(updateTriggerId); await tx.execute(/* sql */ ` @@ -311,7 +324,7 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.DELETE)) { - const deleteTriggerId = `ps_temp_trigger_delete_${id}`; + const deleteTriggerId = `ps_temp_trigger_delete__${destination}__${id}`; triggerIds.push(deleteTriggerId); // Create delete trigger for basic JSON @@ -456,24 +469,4 @@ export class TriggerManagerImpl implements TriggerManager { throw error; } } - - protected async removeTriggerRecord(ctx: LockContext, record: TrackedTableRecord) { - await ctx.execute( - /* sql */ ` - UPDATE ps_kv AS kv - SET - value = ( - SELECT - json_group_array (json_each.value) - FROM - json_each (kv.value) -- Explicitly reference kv.value - WHERE - json_extract (json_each.value, '$.id') != ? - ) - WHERE - key = ?; - `, - [record.id, TRIGGER_TABLE_TRACKING_KEY] - ); - } } diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index c425acaea..b3ffdb8c2 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -144,31 +144,4 @@ describe('Triggers', () => { { interval: 100 } ); }); - - it('should remember table state after disconnectAndClear', async () => { - const db = generateTestDb({ - database: new WASQLiteOpenFactory({ - dbFilename: 'triggers.sqlite', - vfs: WASQLiteVFS.OPFSCoopSyncVFS - }), - schema: TEST_SCHEMA - }); - - await db.triggers.createDiffTrigger({ - source: TEST_SCHEMA.props.customers.name, - destination: 'xxxx_test_1', - when: { - [DiffTriggerOperation.INSERT]: 'TRUE' - } - }); - - await db.disconnectAndClear(); - - const state = await db.get<{ value: string }>('SELECT value FROM ps_kv WHERE key = ?', [ - 'powersync_tables_to_cleanup' - ]); - expect(state).toBeDefined(); - expect(state?.value).toBeDefined(); - expect(JSON.parse(state?.value ?? '[]').length).eq(1); - }); }); From dcba00903fa80f063ff9d3281e8030f1aa45ecf2 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 12:38:06 +0200 Subject: [PATCH 05/12] update trigger naming --- .../src/client/triggers/TriggerManagerImpl.ts | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 6bc5a12c3..fa5e2ade6 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -127,6 +127,10 @@ export class TriggerManagerImpl implements TriggerManager { }; } + 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. */ @@ -134,23 +138,40 @@ export class TriggerManagerImpl implements TriggerManager { // 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_____ - // - Start after first '__': instr(name, '__') + 2 - // - Length: total - start_offset - separator(2) - uuid(36) = length(name) - instr(name, '__') - 39 + // Trigger naming convention: __ps_temp_trigger_____ + // - Remove first '__' with substr(name, 3) + // - Find first '__' in remaining string (this is after operation) + // - Destination starts after that '__': instr(substr(name, 3), '__') + 4 (2 for removed '__' + 2 for found '__') + // - Destination length: length(name) - 39 (to exclude the last 38 chars and adjust for offset) + // - UUID is always last 36 chars + // 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, - instr (name, '__') + 2, - length (name) - instr (name, '__') - 39 - ) as "table", - substr (name, -36) as id + _start_index, + length (name) - _start_index - 37 + ) AS "table", + substr (name, -36) AS id FROM - sqlite_master - WHERE - type = 'trigger' - AND name LIKE 'ps_temp_trigger_%' + trigger_names `); if (trackedItems.length == 0) { @@ -169,8 +190,8 @@ export class TriggerManagerImpl implements TriggerManager { 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( - (value) => `ps_temp_trigger_${value.toLowerCase()}__${trackedItem.table}__${trackedItem.id}` + 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 @@ -280,7 +301,7 @@ export class TriggerManagerImpl implements TriggerManager { `); if (operations.includes(DiffTriggerOperation.INSERT)) { - const insertTriggerId = `ps_temp_trigger_insert__${destination}__${id}`; + const insertTriggerId = this.generateTriggerName(DiffTriggerOperation.INSERT, destination, id); triggerIds.push(insertTriggerId); await tx.execute(/* sql */ ` @@ -302,7 +323,7 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.UPDATE)) { - const updateTriggerId = `ps_temp_trigger_update__${destination}__${id}`; + const updateTriggerId = this.generateTriggerName(DiffTriggerOperation.UPDATE, destination, id); triggerIds.push(updateTriggerId); await tx.execute(/* sql */ ` @@ -324,7 +345,7 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.DELETE)) { - const deleteTriggerId = `ps_temp_trigger_delete__${destination}__${id}`; + const deleteTriggerId = this.generateTriggerName(DiffTriggerOperation.DELETE, destination, id); triggerIds.push(deleteTriggerId); // Create delete trigger for basic JSON @@ -379,7 +400,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(); From f43740082954ce7ee6f749c64dcf36f31edc3d5d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 12:48:12 +0200 Subject: [PATCH 06/12] Add more tests. --- packages/node/tests/trigger.test.ts | 25 ------ packages/web/src/db/PowerSyncDatabase.ts | 14 ++-- packages/web/tests/triggers.test.ts | 101 ++++++++++++++++------- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index 6b366f3ca..3a256b267 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -757,34 +757,9 @@ describe('Triggers', () => { expect(results.length).toEqual(1); }); - const getTrackedTables = async () => { - // The table should be tracked in ps_kv - const { value: trackedTableItemsString } = await database.get<{ value: string }>( - 'SELECT value from ps_kv where key = ?', - ['powersync_tables_to_cleanup'] - ); - - type TrackedTableRecord = { - id: string; - table: string; - }; - - const trackedTableItems: TrackedTableRecord[] = JSON.parse(trackedTableItemsString); - return trackedTableItems; - }; - - const initialTrackedTableItems = await getTrackedTables(); - // There should be an entry for this item - expect(initialTrackedTableItems.find((item) => item.table == table)).toBeDefined(); - // perform a manual cleanup await cleanup(); - const afterCleanupTrackedTableItems = await getTrackedTables(); - - // There should no longer be an entry for this, since we ran cleanup - expect(afterCleanupTrackedTableItems.find((item) => item.table == table)).toBeUndefined(); - // For sanity, the table should not exist const tableRows = await database.getAll<{ name: string }>( `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, diff --git a/packages/web/src/db/PowerSyncDatabase.ts b/packages/web/src/db/PowerSyncDatabase.ts index 3a2514363..2a91ed907 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -155,11 +155,15 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { */ await this.database.init(); } - const config = (this.database as WebDBAdapter).getConfiguration(); - if (config.requiresPersistentTriggers) { - this.triggersImpl.updateDefaults({ - usePersistenceByDefault: true - }); + + // 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({ + usePersistenceByDefault: true + }); + } } } diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index b3ffdb8c2..4879e4c2c 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -4,6 +4,35 @@ 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 automatically configure persistence for OPFS triggers', async () => { const db = generateTestDb({ @@ -47,36 +76,6 @@ describe('Triggers', () => { it('should cleanup persisted trigger tables when opening a new client', { timeout: Infinity }, async () => { debugger; - /** - * Creates a simple iFrame which loads a script which creates a managed trigger. - */ - 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; - }; const openDB = () => generateTestDb({ @@ -144,4 +143,46 @@ describe('Triggers', () => { { 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); + }); + }); }); From 797bde5a2910c260b54d6635991265c0405f0e2e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 14:06:02 +0200 Subject: [PATCH 07/12] cleanup --- .../src/components/providers/SystemProvider.tsx | 2 +- packages/common/src/client/triggers/TriggerManager.ts | 2 ++ packages/web/tests/triggers.test.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) 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 6a52dbf45..d6ac28c01 100644 --- a/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx +++ b/demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx @@ -16,7 +16,7 @@ export const useSupabase = () => React.useContext(SupabaseContext); export const db = new PowerSyncDatabase({ schema: AppSchema, database: new WASQLiteOpenFactory({ - dbFilename: 'test2.sqlite', + dbFilename: 'example.db', vfs: WASQLiteVFS.OPFSCoopSyncVFS }) }); diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 37e658964..1bd414a5b 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -208,6 +208,8 @@ interface BaseCreateDiffTriggerOptions { /** * Using persistence will result in creating a persisted SQLite trigger * and destination table. + * The resources created, although persisted, still have a temporary lifecycle. + * These resources will be disposed automatically when no longer used. */ usePersistence?: boolean; } diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index 4879e4c2c..da7f64158 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -74,7 +74,7 @@ describe('Triggers', () => { expect(initialTableRows.length).toEqual(1); }); - it('should cleanup persisted trigger tables when opening a new client', { timeout: Infinity }, async () => { + it('should cleanup persisted trigger tables when opening a new client', async () => { debugger; const openDB = () => From d88e63037caa7de3bd1ce1b43f9d320b115ca2ea Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 14:16:14 +0200 Subject: [PATCH 08/12] cleanup naming for TriggerClaimManager --- packages/capacitor/src/PowerSyncDatabase.ts | 4 +-- .../src/client/AbstractPowerSyncDatabase.ts | 4 +-- .../triggers/MemoryTriggerClaimManager.ts | 26 +++++++++++++++++++ .../triggers/MemoryTriggerHoldManager.ts | 26 ------------------- .../src/client/triggers/TriggerManager.ts | 19 +++++++++----- .../src/client/triggers/TriggerManagerImpl.ts | 8 +++--- packages/common/src/index.ts | 5 +++- ...ger.ts => NavigatorTriggerClaimManager.ts} | 8 +++--- packages/web/src/db/PowerSyncDatabase.ts | 4 +-- 9 files changed, 56 insertions(+), 48 deletions(-) create mode 100644 packages/common/src/client/triggers/MemoryTriggerClaimManager.ts delete mode 100644 packages/common/src/client/triggers/MemoryTriggerHoldManager.ts rename packages/web/src/db/{NavigatorTriggerHoldManager.ts => NavigatorTriggerClaimManager.ts} (63%) diff --git a/packages/capacitor/src/PowerSyncDatabase.ts b/packages/capacitor/src/PowerSyncDatabase.ts index 20ecf036c..9f7fa6f93 100644 --- a/packages/capacitor/src/PowerSyncDatabase.ts +++ b/packages/capacitor/src/PowerSyncDatabase.ts @@ -1,7 +1,7 @@ import { Capacitor } from '@capacitor/core'; import { DBAdapter, - MemoryTriggerHoldManager, + MemoryTriggerClaimManager, PowerSyncBackendConnector, RequiredAdditionalConnectionOptions, StreamingSyncImplementation, @@ -53,7 +53,7 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase { * We usually only ever have a single tab for capacitor. * Avoiding navigator locks allows insecure contexts (during development). */ - config.holdManager = new MemoryTriggerHoldManager(); + config.claimManager = new MemoryTriggerClaimManager(); } return config; } diff --git a/packages/common/src/client/AbstractPowerSyncDatabase.ts b/packages/common/src/client/AbstractPowerSyncDatabase.ts index 85ad9bd51..86dc83751 100644 --- a/packages/common/src/client/AbstractPowerSyncDatabase.ts +++ b/packages/common/src/client/AbstractPowerSyncDatabase.ts @@ -40,7 +40,7 @@ import { } from './sync/stream/AbstractStreamingSyncImplementation.js'; import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.js'; import { SyncStream } from './sync/sync-streams.js'; -import { MemoryTriggerHoldManager } from './triggers/MemoryTriggerHoldManager.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'; @@ -343,7 +343,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver 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/MemoryTriggerHoldManager.ts b/packages/common/src/client/triggers/MemoryTriggerHoldManager.ts deleted file mode 100644 index 67119600f..000000000 --- a/packages/common/src/client/triggers/MemoryTriggerHoldManager.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TriggerHoldManager } from './TriggerManager.js'; - -/** - * @internal - * @experimental - */ -export class MemoryTriggerHoldManager implements TriggerHoldManager { - // Uses a global store to share the state between potentially multiple instances - private static HOLD_STORE = new Map Promise>(); - - async obtainHold(identifier: string): Promise<() => Promise> { - if (MemoryTriggerHoldManager.HOLD_STORE.has(identifier)) { - throw new Error(`A hold is already present for ${identifier}`); - } - const release = async () => { - MemoryTriggerHoldManager.HOLD_STORE.delete(identifier); - }; - MemoryTriggerHoldManager.HOLD_STORE.set(identifier, release); - - return release; - } - - async checkHold(identifier: string): Promise { - return MemoryTriggerHoldManager.HOLD_STORE.has(identifier); - } -} diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 1bd414a5b..182dfb6ac 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -468,16 +468,16 @@ export interface TriggerManager { * are actively in use. Resource which are not reported as claimed by this interface will be disposed. */ -export interface TriggerHoldManager { +export interface TriggerClaimManager { /** - * Obtains or marks a hold on a certain identifier. - * @returns a callback to release the hold. + * Obtains or marks a claim on a certain identifier. + * @returns a callback to release the claim. */ - obtainHold: (identifier: string) => Promise<() => Promise>; + obtainClaim: (identifier: string) => Promise<() => Promise>; /** - * Checks if a hold is present for an identifier. + * Checks if a claim is present for an identifier. */ - checkHold: (identifier: string) => Promise; + checkClaim: (identifier: string) => Promise; } /** @@ -485,5 +485,10 @@ export interface TriggerHoldManager { * @internal */ export interface TriggerManagerConfig { - holdManager: TriggerHoldManager; + claimManager: TriggerClaimManager; } + +/** + * @deprecated Use {@link TriggerClaimManager} instead + */ +export type TriggerHoldManager = TriggerClaimManager; diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index fa5e2ade6..0da5beec6 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -181,8 +181,8 @@ export class TriggerManagerImpl implements TriggerManager { for (const trackedItem of trackedItems) { // check if there is anything holding on to this item - const hasHold = await this.options.holdManager.checkHold(trackedItem.id); - if (hasHold) { + const hasClaim = await this.options.claimManager.checkClaim(trackedItem.id); + if (hasClaim) { // This does not require cleanup continue; } @@ -246,7 +246,7 @@ export class TriggerManagerImpl implements TriggerManager { const id = await this.getUUID(); - const releasePersistenceHold = usePersistence ? await this.options.holdManager.obtainHold(id) : null; + const releasePersistenceClaim = usePersistence ? await this.options.claimManager.obtainClaim(id) : null; /** * We default to replicating all columns if no columns array is provided. @@ -282,7 +282,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 releasePersistenceHold?.(); + await releasePersistenceClaim?.(); }); }; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5fb117415..2553ffe66 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -34,7 +34,10 @@ export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; export * from './client/Query.js'; -export { MemoryTriggerHoldManager } from './client/triggers/MemoryTriggerHoldManager.js'; +export { + MemoryTriggerClaimManager, + MemoryTriggerClaimManager as MemoryTriggerHoldManager +} from './client/triggers/MemoryTriggerClaimManager.js'; export * from './client/triggers/sanitizeSQL.js'; export * from './client/triggers/TriggerManager.js'; export { TriggerManagerImpl } from './client/triggers/TriggerManagerImpl.js'; diff --git a/packages/web/src/db/NavigatorTriggerHoldManager.ts b/packages/web/src/db/NavigatorTriggerClaimManager.ts similarity index 63% rename from packages/web/src/db/NavigatorTriggerHoldManager.ts rename to packages/web/src/db/NavigatorTriggerClaimManager.ts index 314ca0a4e..65621e52b 100644 --- a/packages/web/src/db/NavigatorTriggerHoldManager.ts +++ b/packages/web/src/db/NavigatorTriggerClaimManager.ts @@ -1,8 +1,8 @@ -import { TriggerHoldManager } from '@powersync/common'; +import { TriggerClaimManager } from '@powersync/common'; import { getNavigatorLocks } from '../shared/navigator'; -export class NavigatorTriggerHoldManager implements TriggerHoldManager { - async obtainHold(identifier: string): Promise<() => Promise> { +export class NavigatorTriggerClaimManager implements TriggerClaimManager { + async obtainClaim(identifier: string): Promise<() => Promise> { return new Promise((resolveReleaser) => { getNavigatorLocks().request(identifier, async () => { await new Promise((releaseLock) => { @@ -12,7 +12,7 @@ export class NavigatorTriggerHoldManager implements TriggerHoldManager { }); } - async checkHold(identifier: string): Promise { + 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 2a91ed907..74a6b7fc5 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -17,7 +17,7 @@ import { } from '@powersync/common'; import { Mutex } from 'async-mutex'; import { getNavigatorLocks } from '../shared/navigator'; -import { NavigatorTriggerHoldManager } from './NavigatorTriggerHoldManager'; +import { NavigatorTriggerClaimManager } from './NavigatorTriggerClaimManager'; import { LockedAsyncDatabaseAdapter } from './adapters/LockedAsyncDatabaseAdapter'; import { WebDBAdapter } from './adapters/WebDBAdapter'; import { WASQLiteOpenFactory } from './adapters/wa-sqlite/WASQLiteOpenFactory'; @@ -170,7 +170,7 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { protected generateTriggerManagerConfig(): TriggerManagerConfig { return { // We need to share hold information between tabs for web - holdManager: new NavigatorTriggerHoldManager() + claimManager: new NavigatorTriggerClaimManager() }; } From fa6f129d9818f212d7a1c64d7139c63702a39b6d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 14:30:52 +0200 Subject: [PATCH 09/12] remove unused items --- packages/common/src/client/triggers/TriggerManager.ts | 5 ----- .../common/src/client/triggers/TriggerManagerImpl.ts | 9 --------- packages/common/src/index.ts | 5 +---- 3 files changed, 1 insertion(+), 18 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 182dfb6ac..b6d69c079 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -487,8 +487,3 @@ export interface TriggerClaimManager { export interface TriggerManagerConfig { claimManager: TriggerClaimManager; } - -/** - * @deprecated Use {@link TriggerClaimManager} instead - */ -export type TriggerHoldManager = TriggerClaimManager; diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 0da5beec6..960aa05d9 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -40,8 +40,6 @@ type TrackedTableRecord = { table: string; }; -export const TRIGGER_TABLE_TRACKING_KEY = 'powersync_tables_to_cleanup'; - const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes /** @@ -137,13 +135,6 @@ export class TriggerManagerImpl implements TriggerManager { 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_____ - // - Remove first '__' with substr(name, 3) - // - Find first '__' in remaining string (this is after operation) - // - Destination starts after that '__': instr(substr(name, 3), '__') + 4 (2 for removed '__' + 2 for found '__') - // - Destination length: length(name) - 39 (to exclude the last 38 chars and adjust for offset) - // - UUID is always last 36 chars // 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 diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 2553ffe66..fbc8f333d 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -34,10 +34,7 @@ export * from './db/schema/Table.js'; export * from './db/schema/TableV2.js'; export * from './client/Query.js'; -export { - MemoryTriggerClaimManager, - MemoryTriggerClaimManager as MemoryTriggerHoldManager -} from './client/triggers/MemoryTriggerClaimManager.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'; From 7150f96ec67c755697b75b3dc5133ccc8357fedb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 16:01:19 +0200 Subject: [PATCH 10/12] cleanup naming --- .changeset/mighty-keys-compete.md | 6 ++++++ .changeset/shaggy-donuts-boil.md | 5 +++++ .../common/src/client/triggers/TriggerManager.ts | 8 +++----- .../common/src/client/triggers/TriggerManagerImpl.ts | 12 ++++++------ packages/node/tests/trigger.test.ts | 2 +- packages/web/src/db/PowerSyncDatabase.ts | 2 +- 6 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 .changeset/mighty-keys-compete.md create mode 100644 .changeset/shaggy-donuts-boil.md 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/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index b6d69c079..4d71d00b3 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -206,12 +206,10 @@ interface BaseCreateDiffTriggerOptions { hooks?: TriggerCreationHooks; /** - * Using persistence will result in creating a persisted SQLite trigger - * and destination table. - * The resources created, although persisted, still have a temporary lifecycle. - * These resources will be disposed automatically when no longer used. + * Use storage-backed (non-TEMP) tables and triggers that persist across sessions. + * These resources are still automatically disposed when no longer claimed. */ - usePersistence?: boolean; + useStorage?: boolean; } /** diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 960aa05d9..8250e7f70 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -18,11 +18,11 @@ export type TriggerManagerImplOptions = TriggerManagerConfig & { }; export type TriggerManagerImplConfiguration = { - usePersistenceByDefault: boolean; + useStorageByDefault: boolean; }; export const DEFAULT_TRIGGER_MANAGER_CONFIGURATION: TriggerManagerImplConfiguration = { - usePersistenceByDefault: false + useStorageByDefault: false }; /** @@ -202,7 +202,7 @@ export class TriggerManagerImpl implements TriggerManager { when, hooks, // Fall back to the provided default if not given on this level - usePersistence = this.defaultConfig.usePersistenceByDefault + useStorage = this.defaultConfig.useStorageByDefault } = options; const operations = Object.keys(when) as DiffTriggerOperation[]; if (operations.length == 0) { @@ -215,7 +215,7 @@ export class TriggerManagerImpl implements TriggerManager { * OR * CREATE ${tableTriggerTypeClause} TRIGGER */ - const tableTriggerTypeClause = !usePersistence ? 'TEMP' : ''; + const tableTriggerTypeClause = !useStorage ? 'TEMP' : ''; const whenClauses = Object.fromEntries( Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]) @@ -237,7 +237,7 @@ export class TriggerManagerImpl implements TriggerManager { const id = await this.getUUID(); - const releasePersistenceClaim = usePersistence ? await this.options.claimManager.obtainClaim(id) : null; + const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null; /** * We default to replicating all columns if no columns array is provided. @@ -273,7 +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 releasePersistenceClaim?.(); + await releaseStorageClaim?.(); }); }; diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index 3a256b267..eca9c6c9e 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -718,7 +718,7 @@ describe('Triggers', () => { [DiffTriggerOperation.UPDATE]: 'TRUE', [DiffTriggerOperation.DELETE]: 'TRUE' }, - usePersistence: true + useStorage: true }); const results = [] as TriggerDiffRecord[]; diff --git a/packages/web/src/db/PowerSyncDatabase.ts b/packages/web/src/db/PowerSyncDatabase.ts index 74a6b7fc5..223ddaa08 100644 --- a/packages/web/src/db/PowerSyncDatabase.ts +++ b/packages/web/src/db/PowerSyncDatabase.ts @@ -161,7 +161,7 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase { const config = (this.database as WebDBAdapter).getConfiguration(); if (config.requiresPersistentTriggers) { this.triggersImpl.updateDefaults({ - usePersistenceByDefault: true + useStorageByDefault: true }); } } From db14b39f3db82f7a672873ec01b670e2b66d03f4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 16:04:12 +0200 Subject: [PATCH 11/12] add test for default temporary triggers --- packages/web/tests/triggers.test.ts | 48 +++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index da7f64158..0eac78783 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -34,6 +34,54 @@ const createTriggerInIframe = () => { }; 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({ From 5ea40a246ea2137d4e33469307b4eebd74e08a03 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 8 Jan 2026 16:50:58 +0200 Subject: [PATCH 12/12] remove debugger statement --- packages/web/tests/triggers.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index 0eac78783..3ed9ed770 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -123,8 +123,6 @@ describe('Triggers', () => { }); it('should cleanup persisted trigger tables when opening a new client', async () => { - debugger; - const openDB = () => generateTestDb({ database: new WASQLiteOpenFactory({