Skip to content

Commit 337fb0c

Browse files
Mark interfaces as experimental. Add warning if schema changed while triggers are active. Fix DELETE triggers not filtering columns with JSON fragment. Add throttleMs as option.
1 parent 34dec96 commit 337fb0c

File tree

5 files changed

+150
-10
lines changed

5 files changed

+150
-10
lines changed

packages/common/src/client/AbstractPowerSyncDatabase.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,11 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
194194
protected runExclusiveMutex: Mutex;
195195

196196
/**
197+
* @experimental
197198
* Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
198199
*/
199200
readonly triggers: TriggerManager;
201+
200202
logger: ILogger;
201203

202204
constructor(options: PowerSyncDatabaseOptionsWithDBAdapter);

packages/common/src/client/triggers/TriggerManager.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { LockContext } from '../../db/DBAdapter.js';
22

33
/**
44
* SQLite operations to track changes for with {@link TriggerManager}
5+
* @experimental
56
*/
67
export enum DiffTriggerOperation {
78
INSERT = 'INSERT',
@@ -10,6 +11,7 @@ export enum DiffTriggerOperation {
1011
}
1112

1213
/**
14+
* @experimental
1315
* Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
1416
* This is the base record structure for all diff records.
1517
*/
@@ -30,6 +32,7 @@ export interface BaseTriggerDiffRecord {
3032
}
3133

3234
/**
35+
* @experimental
3336
* Represents a diff record for a SQLite UPDATE operation.
3437
* This record contains the new value and optionally the previous value.
3538
* Values are stored as JSON strings.
@@ -47,6 +50,7 @@ export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
4750
}
4851

4952
/**
53+
* @experimental
5054
* Represents a diff record for a SQLite INSERT operation.
5155
* This record contains the new value represented as a JSON string.
5256
*/
@@ -59,6 +63,7 @@ export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
5963
}
6064

6165
/**
66+
* @experimental
6267
* Represents a diff record for a SQLite DELETE operation.
6368
* This record contains the new value represented as a JSON string.
6469
*/
@@ -71,6 +76,7 @@ export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
7176
}
7277

7378
/**
79+
* @experimental
7480
* Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
7581
* This is the record structure for all diff records.
7682
*
@@ -85,6 +91,7 @@ export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
8591
export type TriggerDiffRecord = TriggerDiffUpdateRecord | TriggerDiffInsertRecord | TriggerDiffDeleteRecord;
8692

8793
/**
94+
* @experimental
8895
* Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withExtractedDiff} will return records
8996
* with the tracked columns extracted from the JSON value.
9097
* This type represents the structure of such records.
@@ -101,6 +108,7 @@ export type ExtractedTriggerDiffRecord<T> = T & {
101108
};
102109

103110
/**
111+
* @experimental
104112
* Hooks used in the creation of a table diff trigger.
105113
*/
106114
export interface TriggerCreationHooks {
@@ -161,6 +169,7 @@ interface BaseCreateDiffTriggerOptions {
161169
}
162170

163171
/**
172+
* @experimental
164173
* Options for {@link TriggerManager#createDiffTrigger}.
165174
*/
166175
export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
@@ -173,11 +182,13 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
173182
}
174183

175184
/**
185+
* @experimental
176186
* Callback to drop a trigger after it has been created.
177187
*/
178188
export type TriggerRemoveCallback = () => Promise<void>;
179189

180190
/**
191+
* @experimental
181192
* Context for the `onChange` handler provided to {@link TriggerManager#trackTableDiff}.
182193
*/
183194
export interface TriggerDiffHandlerContext extends LockContext {
@@ -225,6 +236,9 @@ export interface TriggerDiffHandlerContext extends LockContext {
225236
* This is similar to {@link withDiff} but extracts the row columns from the tracked JSON value. The diff operation
226237
* data is aliased as `__` columns to avoid column conflicts.
227238
*
239+
* For {@link DiffTriggerOperation#DELETE} operations the previous_value columns are extracted for convenience.
240+
*
241+
*
228242
* ```sql
229243
* CREATE TEMP TABLE DIFF (
230244
* id TEXT,
@@ -251,6 +265,7 @@ export interface TriggerDiffHandlerContext extends LockContext {
251265
}
252266

253267
/**
268+
* @experimental
254269
* Options for tracking changes to a table with {@link TriggerManager#trackTableDiff}.
255270
*/
256271
export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions {
@@ -260,10 +275,20 @@ export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions {
260275
* Diff items are automatically cleared after the handler is invoked.
261276
*/
262277
onChange: (context: TriggerDiffHandlerContext) => Promise<void>;
278+
279+
/**
280+
* The minimum interval, in milliseconds, between {@link onChange} invocations.
281+
* @default {@link DEFAULT_WATCH_THROTTLE_MS}
282+
*/
283+
throttleMs?: number;
263284
}
264285

286+
/**
287+
* @experimental
288+
*/
265289
export interface TriggerManager {
266290
/**
291+
* @experimental
267292
* Creates a temporary trigger which tracks changes to a source table
268293
* and writes changes to a destination table.
269294
* The temporary destination table is created internally and will be dropped when the trigger is removed.
@@ -301,6 +326,7 @@ export interface TriggerManager {
301326
createDiffTrigger(options: CreateDiffTriggerOptions): Promise<TriggerRemoveCallback>;
302327

303328
/**
329+
* @experimental
304330
* Tracks changes for a table. Triggering a provided handler on changes.
305331
* Uses {@link createDiffTrigger} internally to create a temporary destination table.
306332
*

packages/common/src/client/triggers/TriggerManagerImpl.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LockContext } from '../../db/DBAdapter.js';
22
import { Schema } from '../../db/schema/Schema.js';
33
import { type AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js';
4+
import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js';
45
import {
56
CreateDiffTriggerOptions,
67
DiffTriggerOperation,
@@ -103,16 +104,26 @@ export class TriggerManagerImpl implements TriggerManager {
103104
}
104105
};
105106

107+
const disposeWarningListener = this.db.registerListener({
108+
schemaChanged: () => {
109+
this.db.logger.warn(
110+
`The PowerSync schema has changed while previously configured triggers are still operational. This might cause unexpected results.`
111+
);
112+
}
113+
});
114+
106115
/**
107116
* Declare the cleanup function early since if any of the init steps fail,
108117
* we need to ensure we can cleanup the created resources.
109118
* We unfortunately cannot rely on transaction rollback.
110119
*/
111-
const cleanup = async () =>
112-
this.db.writeLock(async (tx) => {
120+
const cleanup = async () => {
121+
disposeWarningListener();
122+
return this.db.writeLock(async (tx) => {
113123
await this.removeTriggers(tx, triggerIds);
114124
await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
115125
});
126+
};
116127

117128
const setup = async (tx: LockContext) => {
118129
// Allow user code to execute in this lock context before the trigger is created.
@@ -187,7 +198,7 @@ export class TriggerManagerImpl implements TriggerManager {
187198
OLD.id,
188199
'DELETE',
189200
strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
190-
OLD.data
201+
${jsonFragment('OLD')}
191202
);
192203
193204
END;
@@ -205,7 +216,7 @@ export class TriggerManagerImpl implements TriggerManager {
205216
}
206217

207218
async trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback> {
208-
const { source, when, columns, operations, hooks } = options;
219+
const { source, when, columns, operations, hooks, throttleMs = DEFAULT_WATCH_THROTTLE_MS } = options;
209220

210221
await this.db.waitForReady();
211222

@@ -284,7 +295,7 @@ export class TriggerManagerImpl implements TriggerManager {
284295
});
285296
}
286297
},
287-
{ tables: [destination], signal: abortController.signal }
298+
{ tables: [destination], signal: abortController.signal, throttleMs }
288299
);
289300

290301
try {

packages/node/tests/trigger.test.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import {
2+
column,
23
DiffTriggerOperation,
34
ExtractedTriggerDiffRecord,
45
sanitizeUUID,
6+
Schema,
7+
Table,
58
TriggerDiffRecord,
69
whenClause
710
} from '@powersync/common';
@@ -425,7 +428,7 @@ describe('Triggers', () => {
425428
);
426429
});
427430

428-
databaseTest('Should allow tracking 0 columns', { timeout: 1000 }, async ({ database }) => {
431+
databaseTest('Should allow tracking 0 columns', async ({ database }) => {
429432
/**
430433
* Tracks the ids of todos reported via the trigger
431434
*/
@@ -499,4 +502,96 @@ describe('Triggers', () => {
499502
{ timeout: 1000, interval: 100 }
500503
);
501504
});
505+
506+
databaseTest('Should only track listed columns', async ({ database }) => {
507+
const newSchema = new Schema({
508+
todos: new Table({
509+
content: column.text,
510+
columnA: column.text,
511+
columnB: column.text
512+
})
513+
});
514+
await database.updateSchema(newSchema);
515+
516+
type NewTodoRecord = (typeof newSchema)['types']['todos'];
517+
518+
const changes: ExtractedTriggerDiffRecord<NewTodoRecord>[] = [];
519+
520+
const createTodo = async (content: string, columnA = 'A', columnB = 'B'): Promise<NewTodoRecord> => {
521+
// Create todos for both lists
522+
return database.writeLock(async (tx) => {
523+
const result = await tx.execute(
524+
/* sql */ `
525+
INSERT INTO
526+
todos (id, content, columnA, columnB)
527+
VALUES
528+
(uuid (), ?, ?, ?) RETURNING id
529+
`,
530+
[content, columnA, columnB]
531+
);
532+
return result.rows?._array?.[0];
533+
});
534+
};
535+
536+
await database.triggers.trackTableDiff({
537+
source: 'todos',
538+
operations: [DiffTriggerOperation.INSERT, DiffTriggerOperation.UPDATE, DiffTriggerOperation.DELETE],
539+
columns: ['columnA'],
540+
onChange: async (context) => {
541+
// Fetches the content of the records at the time of the operation
542+
const extractedDiff = await context.withExtractedDiff<ExtractedTriggerDiffRecord<NewTodoRecord>>(/* sql */ `
543+
SELECT
544+
*
545+
FROM
546+
DIFF
547+
`);
548+
changes.push(...extractedDiff);
549+
}
550+
});
551+
552+
await createTodo('todo 1');
553+
await createTodo('todo 2');
554+
await createTodo('todo 3');
555+
556+
// Do an update operation to ensure only the tracked columns of updated values are stored
557+
await database.execute(/* sql */ `
558+
UPDATE todos
559+
SET
560+
content = 'todo 4'
561+
WHERE
562+
content = 'todo 3'
563+
`);
564+
565+
// Do a delete operation to ensure only the tracked columns of updated values are stored
566+
await database.execute(/* sql */ `
567+
DELETE FROM todos
568+
WHERE
569+
content = 'todo 4'
570+
`);
571+
572+
// Wait for all the changes to be recorded
573+
await vi.waitFor(
574+
async () => {
575+
expect(changes.length).toEqual(5);
576+
},
577+
{ timeout: 1000, interval: 100 }
578+
);
579+
580+
// Inserts should only have the tracked columns
581+
expect(changes[0].__operation).eq(DiffTriggerOperation.INSERT);
582+
expect(changes[1].__operation).eq(DiffTriggerOperation.INSERT);
583+
expect(changes[2].__operation).eq(DiffTriggerOperation.INSERT);
584+
// Should not track this column
585+
expect(changes[0].columnB).toBeUndefined();
586+
587+
expect(changes[3].__operation).eq(DiffTriggerOperation.UPDATE);
588+
expect(changes[3].columnB).toBeUndefined();
589+
expect(changes[3].__previous_value).toBeDefined();
590+
expect(Object.keys(JSON.parse(changes[3].__previous_value))).to.deep.equal(['columnA']);
591+
592+
// For deletes we extract the old value for convenience (there is no new value)
593+
expect(changes[4].__operation).eq(DiffTriggerOperation.DELETE);
594+
expect(changes[4].columnB).toBeUndefined();
595+
expect(changes[4].__previous_value).toBeNull();
596+
});
502597
});

pnpm-workspace.yaml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
packages:
2-
- "demos/*"
3-
- "packages/*"
4-
- "tools/*"
5-
- "docs/"
2+
- demos/*
3+
- packages/*
4+
- tools/*
5+
- docs/
6+
7+
onlyBuiltDependencies:
8+
- '@journeyapps/wa-sqlite'
9+
- esbuild
10+
- react-native-elements
11+
- vue-demi

0 commit comments

Comments
 (0)