Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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. Added `createDiffDestinationTable` helper method to ease the external creation step.
33 changes: 33 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,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;
Comment thread
stevensJourney marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -457,6 +482,14 @@ export interface TriggerManager {
* ```
*/
trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;

/**
* @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<void>;
}

/**
Expand Down
79 changes: 62 additions & 17 deletions packages/common/src/client/triggers/TriggerManagerImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`;
}

/**
Expand Down Expand Up @@ -193,6 +200,23 @@ export class TriggerManagerImpl implements TriggerManager {
});
}

async createDiffDestinationTable(tableName: string, options?: CreateDiffDestinationTableOptions): Promise<void> {
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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -272,27 +298,36 @@ 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);
const insertTriggerId = this.generateTriggerName(
DiffTriggerOperation.INSERT,
destination,
id,
manageDestinationExternally
);
triggerIds.push(insertTriggerId);

await tx.execute(/* sql */ `
Expand All @@ -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 */ `
Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions packages/node/tests/trigger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>[] = [];

Expand Down Expand Up @@ -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');`);
Expand Down