Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-steaks-drop.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/common/src/client/triggers/TriggerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ 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.
*/
manageDestinationExternally?: boolean;
Comment thread
stevensJourney marked this conversation as resolved.
}

/**
Expand Down
27 changes: 16 additions & 11 deletions packages/common/src/client/triggers/TriggerManagerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,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;
Expand Down Expand Up @@ -272,24 +273,28 @@ 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?.();
});
};

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);
Expand Down
72 changes: 72 additions & 0 deletions packages/node/tests/trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,78 @@ 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' },
useStorage: true, // persistent table so we can verify via sqlite_master
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' },
useStorage: 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<string>[] = [];

Expand Down