diff --git a/.changeset/brown-steaks-drop.md b/.changeset/brown-steaks-drop.md new file mode 100644 index 000000000..6c6bfcfed --- /dev/null +++ b/.changeset/brown-steaks-drop.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Added `manageDestinationExternally` option to diff trigger creation, escaping all internal management of the destination table. User is responsible for table creation and cleanup. Added `createDiffDestinationTable` helper method to ease the external creation step. diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 6b73ce9c6..7e330f959 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -223,6 +223,19 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions { * This table will be dropped once the trigger is removed. */ destination: string; + + /** + * When true, the diff trigger will not create or drop the destination table. + * The caller is responsible for ensuring the table exists with the correct + * schema before creating the trigger and for dropping it when no longer needed. + * + * This is intended for advanced use cases, such as maintaining the destination table + * across trigger recreations. + * Note: While `useStorage` controls whether the destination table is persisted to disk + * across sessions, `manageDestinationExternally` controls who is responsible for the + * table's lifecycle - the SDK (default) or the caller. + */ + manageDestinationExternally?: boolean; } /** @@ -354,6 +367,18 @@ export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions { throttleMs?: number; } +/** + * @experimental + * Options for creating a diff trigger destination table with {@link TriggerManager#createDiffDestinationTable}. + */ +export interface CreateDiffDestinationTableOptions { + /** If true, the table will be created as a temporary table. Defaults to true. */ + temporary?: boolean; + + /** If true, the table will only be created if it does not already exist. Defaults to false. */ + onlyIfNotExists?: boolean; +} + /** * @experimental */ @@ -457,6 +482,14 @@ export interface TriggerManager { * ``` */ trackTableDiff(options: TrackDiffOptions): Promise; + + /** + * @experimental + * Creates a diff trigger destination table on the database with the given configuration. + * By default this is handled automatically when creating a diff trigger, but needs to + * be done manually if `manageDestinationExternally` is set to true. + */ + createDiffDestinationTable(tableName: string, options?: CreateDiffDestinationTableOptions): Promise; } /** diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 6a6d7d00d..bebaece8d 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -3,6 +3,7 @@ import { Schema } from '../../db/schema/Schema.js'; import type { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js'; import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js'; import { + CreateDiffDestinationTableOptions, CreateDiffTriggerOptions, DiffTriggerOperation, TrackDiffOptions, @@ -129,8 +130,14 @@ export class TriggerManagerImpl implements TriggerManager { }; } - protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string) { - return `__ps_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`; + protected generateTriggerName( + operation: DiffTriggerOperation, + destinationTable: string, + triggerId: string, + managedExternally = false + ) { + const managedTerm = managedExternally ? '_external' : ''; + return `__ps${managedTerm}_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`; } /** @@ -193,6 +200,23 @@ export class TriggerManagerImpl implements TriggerManager { }); } + async createDiffDestinationTable(tableName: string, options?: CreateDiffDestinationTableOptions): Promise { + const { temporary = true, onlyIfNotExists = false } = options ?? {}; + const tableTriggerTypeClause = temporary ? 'TEMP' : ''; + const onlyIfNotExistsClause = onlyIfNotExists ? 'IF NOT EXISTS' : ''; + + await this.db.execute(/* sql */ ` + CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExistsClause} ${tableName} ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT, + operation TEXT, + timestamp TEXT, + value TEXT, + previous_value TEXT + ) + `); + } + async createDiffTrigger(options: CreateDiffTriggerOptions) { await this.db.waitForReady(); const { @@ -201,6 +225,7 @@ export class TriggerManagerImpl implements TriggerManager { columns, when, hooks, + manageDestinationExternally = false, // Fall back to the provided default if not given on this level useStorage = this.defaultConfig.useStorageByDefault } = options; @@ -237,7 +262,8 @@ export class TriggerManagerImpl implements TriggerManager { const id = await this.getUUID(); - const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null; + const releaseStorageClaim = + useStorage && !manageDestinationExternally ? await this.options.claimManager.obtainClaim(id) : null; /** * We default to replicating all columns if no columns array is provided. @@ -272,7 +298,9 @@ export class TriggerManagerImpl implements TriggerManager { disposeWarningListener(); return this.db.writeLock(async (tx) => { await this.removeTriggers(tx, triggerIds); - await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); + if (!manageDestinationExternally) { + await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); + } await releaseStorageClaim?.(); }); }; @@ -280,19 +308,26 @@ export class TriggerManagerImpl implements TriggerManager { const setup = async (tx: LockContext) => { // Allow user code to execute in this lock context before the trigger is created. await hooks?.beforeCreate?.(tx); - await tx.execute(/* sql */ ` - CREATE ${tableTriggerTypeClause} TABLE ${destination} ( - operation_id INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT, - operation TEXT, - timestamp TEXT, - value TEXT, - previous_value TEXT - ) - `); + if (!manageDestinationExternally) { + await tx.execute(/* sql */ ` + CREATE ${tableTriggerTypeClause} TABLE ${destination} ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT, + operation TEXT, + timestamp TEXT, + value TEXT, + previous_value TEXT + ) + `); + } if (operations.includes(DiffTriggerOperation.INSERT)) { - const insertTriggerId = this.generateTriggerName(DiffTriggerOperation.INSERT, destination, id); + const insertTriggerId = this.generateTriggerName( + DiffTriggerOperation.INSERT, + destination, + id, + manageDestinationExternally + ); triggerIds.push(insertTriggerId); await tx.execute(/* sql */ ` @@ -314,7 +349,12 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.UPDATE)) { - const updateTriggerId = this.generateTriggerName(DiffTriggerOperation.UPDATE, destination, id); + const updateTriggerId = this.generateTriggerName( + DiffTriggerOperation.UPDATE, + destination, + id, + manageDestinationExternally + ); triggerIds.push(updateTriggerId); await tx.execute(/* sql */ ` @@ -336,7 +376,12 @@ export class TriggerManagerImpl implements TriggerManager { } if (operations.includes(DiffTriggerOperation.DELETE)) { - const deleteTriggerId = this.generateTriggerName(DiffTriggerOperation.DELETE, destination, id); + const deleteTriggerId = this.generateTriggerName( + DiffTriggerOperation.DELETE, + destination, + id, + manageDestinationExternally + ); triggerIds.push(deleteTriggerId); // Create delete trigger for basic JSON diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index eca9c6c9e..33082f0e1 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -613,6 +613,55 @@ describe('Triggers', () => { expect(changes[4].__previous_value).toBeNull(); }); + databaseTest('manageDestinationExternally: should not drop destination table on dispose', async ({ database }) => { + const table = 'persist_dest_dispose_test'; + + await database.triggers.createDiffDestinationTable(table, { onlyIfNotExists: true, temporary: true }); + + const dispose = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, + manageDestinationExternally: true + }); + + // Table must exist before dispose + let rows = await database.execute(`SELECT name FROM sqlite_temp_master WHERE type='table' AND name = ?`, [table]); + expect(rows.rows._array.length).toEqual(1); + + await dispose(); + + // Table must STILL exist + rows = await database.execute(`SELECT name FROM sqlite_temp_master WHERE type='table' AND name = ?`, [table]); + expect(rows.rows._array.length).toEqual(1); + + // Manual cleanup so the test doesn't leak + await database.execute(`DROP TABLE IF EXISTS ${table}`); + }); + + databaseTest( + 'manageDestinationExternally: should allow reusing an existing destination table', + async ({ database }) => { + const table = 'persist_dest_reuse_test'; + + // Manually create the destination table (simulates a table that persisted from a prior session) + await database.triggers.createDiffDestinationTable(table, { onlyIfNotExists: true, temporary: true }); + + // Must NOT throw even though the table already exists. + const dispose = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, + manageDestinationExternally: true + }); + + await dispose(); + + // Manual cleanup + await database.execute(`DROP TABLE IF EXISTS ${table}`); + } + ); + databaseTest('Should cast operation_id as string with withDiff option', async ({ database }) => { const results: TriggerDiffRecord[] = []; @@ -748,6 +797,7 @@ describe('Triggers', () => { `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');`);