From 98c37b1dfaf5a37883567d34b61a6b0ed06cb2b6 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 5 Mar 2026 10:59:53 +0200 Subject: [PATCH 01/12] Added `persistDestination` option to diff trigger creation. Allows the destination table to be persisted beyond cleanup of a trigger, also allows the creation to complete even if the destination table already exists. --- .changeset/brown-steaks-drop.md | 5 ++ .../src/client/triggers/TriggerManager.ts | 6 ++ .../src/client/triggers/TriggerManagerImpl.ts | 9 ++- packages/node/tests/trigger.test.ts | 65 +++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 .changeset/brown-steaks-drop.md diff --git a/.changeset/brown-steaks-drop.md b/.changeset/brown-steaks-drop.md new file mode 100644 index 000000000..b2c5cfcfd --- /dev/null +++ b/.changeset/brown-steaks-drop.md @@ -0,0 +1,5 @@ +--- +'@powersync/common': minor +--- + +Added `persistDestination` option to diff trigger creation. Allows the destination table to be persisted beyond cleanup of a trigger, also allows the creation to complete even if the destination table already exists. diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 6b73ce9c6..faff0f188 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -223,6 +223,12 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions { * This table will be dropped once the trigger is removed. */ destination: string; + + /** + * The destination table persists beyond this trigger's lifetime and is not automatically dropped when the trigger is removed. + * Additionally, if the trigger already exists with the same destination, it will be reused instead of failing with a name conflict error. + */ + persistDestination?: boolean; } /** diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 6a6d7d00d..068d4a0fc 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -201,6 +201,7 @@ export class TriggerManagerImpl implements TriggerManager { columns, when, hooks, + persistDestination = false, // Fall back to the provided default if not given on this level useStorage = this.defaultConfig.useStorageByDefault } = options; @@ -268,11 +269,13 @@ export class TriggerManagerImpl implements TriggerManager { * we need to ensure we can cleanup the created resources. * We unfortunately cannot rely on transaction rollback. */ - const cleanup = async () => { + const cleanup = async (force?: boolean) => { disposeWarningListener(); return this.db.writeLock(async (tx) => { await this.removeTriggers(tx, triggerIds); - await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); + if (!persistDestination || force) { + await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); + } await releaseStorageClaim?.(); }); }; @@ -281,7 +284,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 ${tableTriggerTypeClause} TABLE ${destination} ( + CREATE ${tableTriggerTypeClause} TABLE ${persistDestination ? 'IF NOT EXISTS ' : ''}${destination} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, operation TEXT, diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index eca9c6c9e..8b6bb65b8 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -613,6 +613,71 @@ describe('Triggers', () => { expect(changes[4].__previous_value).toBeNull(); }); + databaseTest('persistDestination: should not drop destination table on dispose', async ({ database }) => { + const table = 'persist_dest_dispose_test'; + + const dispose = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, + useStorage: true, // persistent table so we can verify via sqlite_master + persistDestination: true + }); + + // Table must exist before dispose + let rows = await database.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [table] + ); + expect(rows.length).toEqual(1); + + await dispose(); + + // Table must STILL exist — currently FAILS (impl drops the table unconditionally) + rows = await database.getAll<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, + [table] + ); + expect(rows.length).toEqual(1); + + // Manual cleanup so the test doesn't leak + await database.execute(`DROP TABLE IF EXISTS ${table}`); + }); + + databaseTest( + 'persistDestination: 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.execute(` + CREATE TABLE ${table} ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT, + operation TEXT, + timestamp TEXT, + value TEXT, + previous_value TEXT + ) + `); + + // Must NOT throw even though the table already exists. + // Currently FAILS — impl runs bare CREATE TABLE which SQLite rejects with "table already exists". + const dispose = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, + useStorage: true, + persistDestination: 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[] = []; From 85869e8247c6c777e91f6ef42811287e30d6c3fd Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 5 Mar 2026 13:36:18 +0200 Subject: [PATCH 02/12] Updating force usage. --- packages/common/src/client/triggers/TriggerManager.ts | 3 ++- packages/common/src/client/triggers/TriggerManagerImpl.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index faff0f188..e357f26ec 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -234,8 +234,9 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions { /** * @experimental * Callback to drop a trigger after it has been created. + * When invoked with force=true, it will also drop the destination table even if `persistDestination` was true. */ -export type TriggerRemoveCallback = () => Promise; +export type TriggerRemoveCallback = (force?: boolean) => Promise; /** * @experimental diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 068d4a0fc..315a399a9 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -471,9 +471,9 @@ export class TriggerManagerImpl implements TriggerManager { hooks }); - return async () => { + return async (force?: boolean) => { abortOnChange(); - await removeTrigger(); + await removeTrigger(force); }; } catch (error) { try { From cc187a012b8a42d54a79e7fdf72da7d4a40a7724 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 5 Mar 2026 13:43:38 +0200 Subject: [PATCH 03/12] Fixed unit test. --- packages/web/tests/triggers.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index 7a356d6a8..585879f5b 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -55,7 +55,7 @@ describe('Triggers', () => { } }); - onTestFinished(disposeTrigger); + onTestFinished(() => disposeTrigger()); await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); @@ -103,7 +103,7 @@ describe('Triggers', () => { } }); - onTestFinished(disposeTrigger); + onTestFinished(() => disposeTrigger()); await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); @@ -221,7 +221,7 @@ describe('Triggers', () => { } }); - onTestFinished(disposeTrigger); + onTestFinished(() => disposeTrigger()); // Perform an insert from client B await dbB.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'from-client-b')"); From 8e501da5ea49d47385d7618b96275cc58a2d6991 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 5 Mar 2026 14:28:50 +0200 Subject: [PATCH 04/12] Switched to `manageDestinationExternally` option instead, escaping all internal management of the destination table. --- .../src/client/triggers/TriggerManager.ts | 10 ++-- .../src/client/triggers/TriggerManagerImpl.ts | 32 +++++----- packages/node/tests/trigger.test.ts | 59 +++++++++++-------- packages/web/tests/triggers.test.ts | 6 +- 4 files changed, 58 insertions(+), 49 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index e357f26ec..32049d14a 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -225,18 +225,18 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions { destination: string; /** - * The destination table persists beyond this trigger's lifetime and is not automatically dropped when the trigger is removed. - * Additionally, if the trigger already exists with the same destination, it will be reused instead of failing with a name conflict error. + * 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. */ - persistDestination?: boolean; + manageDestinationExternally?: boolean; } /** * @experimental * Callback to drop a trigger after it has been created. - * When invoked with force=true, it will also drop the destination table even if `persistDestination` was true. */ -export type TriggerRemoveCallback = (force?: boolean) => Promise; +export type TriggerRemoveCallback = () => Promise; /** * @experimental diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 315a399a9..370ca60ec 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -201,7 +201,7 @@ export class TriggerManagerImpl implements TriggerManager { columns, when, hooks, - persistDestination = false, + manageDestinationExternally = false, // Fall back to the provided default if not given on this level useStorage = this.defaultConfig.useStorageByDefault } = options; @@ -269,11 +269,11 @@ export class TriggerManagerImpl implements TriggerManager { * we need to ensure we can cleanup the created resources. * We unfortunately cannot rely on transaction rollback. */ - const cleanup = async (force?: boolean) => { + const cleanup = async () => { disposeWarningListener(); return this.db.writeLock(async (tx) => { await this.removeTriggers(tx, triggerIds); - if (!persistDestination || force) { + if (!manageDestinationExternally) { await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`); } await releaseStorageClaim?.(); @@ -283,16 +283,18 @@ 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 ${persistDestination ? 'IF NOT EXISTS ' : ''}${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); @@ -471,9 +473,9 @@ export class TriggerManagerImpl implements TriggerManager { hooks }); - return async (force?: boolean) => { + return async () => { abortOnChange(); - await removeTrigger(force); + await removeTrigger(); }; } catch (error) { try { diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index 8b6bb65b8..fa664727e 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -616,12 +616,24 @@ describe('Triggers', () => { databaseTest('persistDestination: should not drop destination table on dispose', async ({ database }) => { const table = 'persist_dest_dispose_test'; + // Manually create the destination table (simulates a table that persisted from a prior session) + await database.execute(` + CREATE TABLE ${table} ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT, + operation TEXT, + timestamp TEXT, + value TEXT, + previous_value TEXT + ) + `); + const dispose = await database.triggers.createDiffTrigger({ source: 'todos', destination: table, when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, useStorage: true, // persistent table so we can verify via sqlite_master - persistDestination: true + manageDestinationExternally: true }); // Table must exist before dispose @@ -633,24 +645,21 @@ describe('Triggers', () => { await dispose(); - // Table must STILL exist — currently FAILS (impl drops the table unconditionally) - rows = await database.getAll<{ name: string }>( - `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, - [table] - ); + // Table must STILL exist + rows = await database.getAll<{ name: string }>(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, [ + table + ]); expect(rows.length).toEqual(1); // Manual cleanup so the test doesn't leak await database.execute(`DROP TABLE IF EXISTS ${table}`); }); - databaseTest( - 'persistDestination: should allow reusing an existing destination table', - async ({ database }) => { - const table = 'persist_dest_reuse_test'; + databaseTest('persistDestination: 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.execute(` + // Manually create the destination table (simulates a table that persisted from a prior session) + await database.execute(` CREATE TABLE ${table} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, @@ -661,22 +670,20 @@ describe('Triggers', () => { ) `); - // Must NOT throw even though the table already exists. - // Currently FAILS — impl runs bare CREATE TABLE which SQLite rejects with "table already exists". - const dispose = await database.triggers.createDiffTrigger({ - source: 'todos', - destination: table, - when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, - useStorage: true, - persistDestination: true - }); + // Must NOT throw even though the table already exists. + const dispose = await database.triggers.createDiffTrigger({ + source: 'todos', + destination: table, + when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, + useStorage: true, + manageDestinationExternally: true + }); - await dispose(); + await dispose(); - // Manual cleanup - await database.execute(`DROP TABLE IF EXISTS ${table}`); - } - ); + // 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[] = []; diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index 585879f5b..7a356d6a8 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -55,7 +55,7 @@ describe('Triggers', () => { } }); - onTestFinished(() => disposeTrigger()); + onTestFinished(disposeTrigger); await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); @@ -103,7 +103,7 @@ describe('Triggers', () => { } }); - onTestFinished(() => disposeTrigger()); + onTestFinished(disposeTrigger); await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'test')"); @@ -221,7 +221,7 @@ describe('Triggers', () => { } }); - onTestFinished(() => disposeTrigger()); + onTestFinished(disposeTrigger); // Perform an insert from client B await dbB.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'from-client-b')"); From 547726432acdb6f6f12e900cacacb74c4d8dcc7a Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Thu, 5 Mar 2026 14:37:17 +0200 Subject: [PATCH 05/12] Changeset entry. --- .changeset/brown-steaks-drop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/brown-steaks-drop.md b/.changeset/brown-steaks-drop.md index b2c5cfcfd..2b6544ef0 100644 --- a/.changeset/brown-steaks-drop.md +++ b/.changeset/brown-steaks-drop.md @@ -2,4 +2,4 @@ '@powersync/common': minor --- -Added `persistDestination` option to diff trigger creation. Allows the destination table to be persisted beyond cleanup of a trigger, also allows the creation to complete even if the destination table already exists. +Added `manageDestinationExternally` option to diff trigger creation, escaping all internal management of the destination table. User is responsible for table creation and cleanup. From d1471dc1517f2e2ae0fdc12ebf3bd00cb5accdb3 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 12:38:32 +0200 Subject: [PATCH 06/12] Added `createDiffDestinationTable`. --- .changeset/brown-steaks-drop.md | 2 +- .../src/client/triggers/TriggerManagerImpl.ts | 45 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/.changeset/brown-steaks-drop.md b/.changeset/brown-steaks-drop.md index 2b6544ef0..6c6bfcfed 100644 --- a/.changeset/brown-steaks-drop.md +++ b/.changeset/brown-steaks-drop.md @@ -2,4 +2,4 @@ '@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 `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/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 370ca60ec..55d70bede 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -193,6 +193,36 @@ export class TriggerManagerImpl implements TriggerManager { }); } + /** + * Creates a diff trigger destination table on the database with the given configuration. + */ + async createDiffDestinationTable( + tableName: string, + options?: { + /** If true, the table will be created as a regular persisted table. If false or not provided, the table will be created as a TEMP table. */ + useStorage?: boolean; + /** If true, the table will only be created if it does not already exist. This can be useful when `manageDestinationExternally` is true. */ + onlyIfNotExists?: boolean; + /** An optional LockContext to use for the table creation. If not provided, a default context will be used. */ + lockContext?: LockContext; + } + ): Promise { + const tableTriggerTypeClause = !options?.useStorage ? 'TEMP' : ''; + const onlyIfNotExists = options?.onlyIfNotExists ? 'IF NOT EXISTS' : ''; + const ctx = options?.lockContext ?? this.db.database; + + await ctx.execute(/* sql */ ` + CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${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 { @@ -284,16 +314,11 @@ export class TriggerManagerImpl implements TriggerManager { // Allow user code to execute in this lock context before the trigger is created. await hooks?.beforeCreate?.(tx); 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 - ) - `); + await this.createDiffDestinationTable(destination, { + lockContext: tx, + useStorage, + onlyIfNotExists: false + }); } if (operations.includes(DiffTriggerOperation.INSERT)) { From afa3e601f0924850aa1378c035a3b951ef39413f Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 13:10:43 +0200 Subject: [PATCH 07/12] Comments. --- packages/common/src/client/triggers/TriggerManager.ts | 6 ++++++ packages/common/src/client/triggers/TriggerManagerImpl.ts | 2 ++ packages/node/tests/trigger.test.ts | 2 -- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 32049d14a..4bc293bba 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -228,6 +228,12 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions { * 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; } diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 55d70bede..80add345b 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -195,6 +195,8 @@ export class TriggerManagerImpl implements TriggerManager { /** * Creates a diff trigger destination table on the database with the given configuration. + * By default this is invoked internally when creating a diff trigger, but can + * be used manually if `manageDestinationExternally` is set to true. */ async createDiffDestinationTable( tableName: string, diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index fa664727e..9ea131dec 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -632,7 +632,6 @@ describe('Triggers', () => { source: 'todos', destination: table, when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, - useStorage: true, // persistent table so we can verify via sqlite_master manageDestinationExternally: true }); @@ -675,7 +674,6 @@ describe('Triggers', () => { source: 'todos', destination: table, when: { [DiffTriggerOperation.INSERT]: 'TRUE' }, - useStorage: true, manageDestinationExternally: true }); From 3970e5004ec9dd07cb9aa9d29cc74dca6c132a85 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 13:31:43 +0200 Subject: [PATCH 08/12] Externally managed triggers now have a different name pattern, resource cleanup regex won't automatically clear externally managed triggers. --- .../src/client/triggers/TriggerManagerImpl.ts | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 80add345b..e9fd7de11 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -129,8 +129,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}`; } /** @@ -324,7 +330,12 @@ export class TriggerManagerImpl implements TriggerManager { } 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 */ ` @@ -346,7 +357,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 */ ` @@ -368,7 +384,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 From c4b6528d6452b712be3e01c88f9629de2cd5031a Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 14:48:55 +0200 Subject: [PATCH 09/12] Removed tests. Updated comments. --- .../src/client/triggers/TriggerManager.ts | 20 ++++++ .../src/client/triggers/TriggerManagerImpl.ts | 49 ++++++------- packages/node/tests/trigger.test.ts | 70 ------------------- 3 files changed, 45 insertions(+), 94 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 4bc293bba..6f01d2774 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -367,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 false. */ + temporary?: boolean; + + /** If true, the table will only be created if it does not already exist. This can be useful when `manageDestinationExternally` is true. */ + onlyIfNotExists?: boolean; +} + /** * @experimental */ @@ -470,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 e9fd7de11..2e70acc0c 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, @@ -199,27 +200,22 @@ export class TriggerManagerImpl implements TriggerManager { }); } - /** - * Creates a diff trigger destination table on the database with the given configuration. - * By default this is invoked internally when creating a diff trigger, but can - * be used manually if `manageDestinationExternally` is set to true. - */ - async createDiffDestinationTable( - tableName: string, - options?: { - /** If true, the table will be created as a regular persisted table. If false or not provided, the table will be created as a TEMP table. */ - useStorage?: boolean; - /** If true, the table will only be created if it does not already exist. This can be useful when `manageDestinationExternally` is true. */ - onlyIfNotExists?: boolean; - /** An optional LockContext to use for the table creation. If not provided, a default context will be used. */ - lockContext?: LockContext; - } - ): Promise { - const tableTriggerTypeClause = !options?.useStorage ? 'TEMP' : ''; + async createDiffDestinationTable(tableName: string, options?: CreateDiffDestinationTableOptions): Promise { + const tableTriggerTypeClause = options?.temporary ? 'TEMP' : ''; const onlyIfNotExists = options?.onlyIfNotExists ? 'IF NOT EXISTS' : ''; - const ctx = options?.lockContext ?? this.db.database; - await ctx.execute(/* sql */ ` + let x = ` + CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${tableName} ( + operation_id INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT, + operation TEXT, + timestamp TEXT, + value TEXT, + previous_value TEXT + ) + `; + console.log(x); + await this.db.execute(/* sql */ ` CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${tableName} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, @@ -322,11 +318,16 @@ export class TriggerManagerImpl implements TriggerManager { // Allow user code to execute in this lock context before the trigger is created. await hooks?.beforeCreate?.(tx); if (!manageDestinationExternally) { - await this.createDiffDestinationTable(destination, { - lockContext: tx, - useStorage, - onlyIfNotExists: false - }); + 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)) { diff --git a/packages/node/tests/trigger.test.ts b/packages/node/tests/trigger.test.ts index 9ea131dec..eca9c6c9e 100644 --- a/packages/node/tests/trigger.test.ts +++ b/packages/node/tests/trigger.test.ts @@ -613,76 +613,6 @@ describe('Triggers', () => { expect(changes[4].__previous_value).toBeNull(); }); - databaseTest('persistDestination: should not drop destination table on dispose', async ({ database }) => { - const table = 'persist_dest_dispose_test'; - - // Manually create the destination table (simulates a table that persisted from a prior session) - await database.execute(` - CREATE TABLE ${table} ( - operation_id INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT, - operation TEXT, - timestamp TEXT, - value TEXT, - previous_value TEXT - ) - `); - - 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.getAll<{ name: string }>( - `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, - [table] - ); - expect(rows.length).toEqual(1); - - await dispose(); - - // Table must STILL exist - rows = await database.getAll<{ name: string }>(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`, [ - table - ]); - expect(rows.length).toEqual(1); - - // Manual cleanup so the test doesn't leak - await database.execute(`DROP TABLE IF EXISTS ${table}`); - }); - - databaseTest('persistDestination: 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.execute(` - CREATE TABLE ${table} ( - operation_id INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT, - operation TEXT, - timestamp TEXT, - value TEXT, - previous_value TEXT - ) - `); - - // 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[] = []; From 2b31a62d4647f2d59d443a091f1d833e9c55bb8c Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 15:02:33 +0200 Subject: [PATCH 10/12] Removed logging. --- .../common/src/client/triggers/TriggerManagerImpl.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 2e70acc0c..b77041118 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -204,17 +204,6 @@ export class TriggerManagerImpl implements TriggerManager { const tableTriggerTypeClause = options?.temporary ? 'TEMP' : ''; const onlyIfNotExists = options?.onlyIfNotExists ? 'IF NOT EXISTS' : ''; - let x = ` - CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${tableName} ( - operation_id INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT, - operation TEXT, - timestamp TEXT, - value TEXT, - previous_value TEXT - ) - `; - console.log(x); await this.db.execute(/* sql */ ` CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${tableName} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, From e3a8fa0f9d45847ace6ecfa173a01039b1df1ca1 Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Fri, 6 Mar 2026 15:19:47 +0200 Subject: [PATCH 11/12] Readded tests. --- packages/node/tests/trigger.test.ts | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) 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');`); From 340adf0c2fa1618f0d6a80ea2695adf3c27dd5cf Mon Sep 17 00:00:00 2001 From: Christiaan Landman Date: Mon, 9 Mar 2026 12:10:21 +0200 Subject: [PATCH 12/12] Updated default values, no longer obtaining a claim when managing externally. --- packages/common/src/client/triggers/TriggerManager.ts | 4 ++-- .../common/src/client/triggers/TriggerManagerImpl.ts | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/common/src/client/triggers/TriggerManager.ts b/packages/common/src/client/triggers/TriggerManager.ts index 6f01d2774..7e330f959 100644 --- a/packages/common/src/client/triggers/TriggerManager.ts +++ b/packages/common/src/client/triggers/TriggerManager.ts @@ -372,10 +372,10 @@ export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions { * 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 false. */ + /** 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. This can be useful when `manageDestinationExternally` is true. */ + /** If true, the table will only be created if it does not already exist. Defaults to false. */ onlyIfNotExists?: boolean; } diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index b77041118..bebaece8d 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -201,11 +201,12 @@ export class TriggerManagerImpl implements TriggerManager { } async createDiffDestinationTable(tableName: string, options?: CreateDiffDestinationTableOptions): Promise { - const tableTriggerTypeClause = options?.temporary ? 'TEMP' : ''; - const onlyIfNotExists = options?.onlyIfNotExists ? 'IF NOT EXISTS' : ''; + const { temporary = true, onlyIfNotExists = false } = options ?? {}; + const tableTriggerTypeClause = temporary ? 'TEMP' : ''; + const onlyIfNotExistsClause = onlyIfNotExists ? 'IF NOT EXISTS' : ''; await this.db.execute(/* sql */ ` - CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExists} ${tableName} ( + CREATE ${tableTriggerTypeClause} TABLE ${onlyIfNotExistsClause} ${tableName} ( operation_id INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT, operation TEXT, @@ -261,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.