Skip to content

Commit 560891d

Browse files
improve API definitions
1 parent 449c09a commit 560891d

File tree

4 files changed

+360
-110
lines changed

4 files changed

+360
-110
lines changed

.prettierrc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"jsxBracketSameLine": true,
66
"useTabs": false,
77
"printWidth": 120,
8-
"trailingComma": "none"
8+
"trailingComma": "none",
9+
"plugins": ["prettier-plugin-embed", "prettier-plugin-sql"]
910
}

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

Lines changed: 109 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { LockContext } from 'src/db/DBAdapter.js';
22

3+
/**
4+
* SQLite operations to track changes for with {@link TriggerManager}
5+
*/
36
export enum DiffTriggerOperation {
47
INSERT = 'INSERT',
58
UPDATE = 'UPDATE',
69
DELETE = 'DELETE'
710
}
811

12+
/**
13+
* Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
14+
* This is the base record structure for all diff records.
15+
*/
916
export interface BaseTriggerDiffRecord {
1017
id: string;
1118
operation: DiffTriggerOperation;
@@ -16,31 +23,55 @@ export interface BaseTriggerDiffRecord {
1623
timestamp: string;
1724
}
1825

26+
/**
27+
* Represents a diff record for a SQLite UPDATE operation.
28+
* This record contains the new value and optionally the previous value.
29+
* Values are stored as JSON strings.
30+
*/
1931
export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
2032
operation: DiffTriggerOperation.UPDATE;
2133
value: string;
2234
previous_value?: string;
2335
}
2436

37+
/**
38+
* Represents a diff record for a SQLite INSERT operation.
39+
* This record contains the new value represented as a JSON string.
40+
*/
2541
export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
2642
operation: DiffTriggerOperation.INSERT;
2743
value: string;
2844
}
2945

46+
/**
47+
* Represents a diff record for a SQLite DELETE operation.
48+
* This record contains the new value represented as a JSON string.
49+
*/
3050
export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
3151
operation: DiffTriggerOperation.DELETE;
3252
}
3353

54+
/**
55+
* Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
56+
* This is the record structure for all diff records.
57+
*/
3458
export type TriggerDiffRecord = TriggerDiffUpdateRecord | TriggerDiffInsertRecord | TriggerDiffDeleteRecord;
3559

60+
/**
61+
* Hooks used in the creation of a table diff trigger.
62+
*/
3663
export interface TriggerCreationHooks {
3764
/**
38-
* Executed inside the write lock before the trigger is created.
65+
* Executed inside a write lock before the trigger is created.
3966
*/
4067
beforeCreate?: (context: LockContext) => Promise<void>;
4168
}
4269

43-
export interface CreateDiffTriggerOptions {
70+
/**
71+
* Common interface for options used in creating a diff trigger.
72+
*/
73+
74+
interface BaseCreateDiffTriggerOptions {
4475
/**
4576
* Source table/view to trigger and track changes from.
4677
* This should be present in the PowerSync database's schema.
@@ -52,27 +83,26 @@ export interface CreateDiffTriggerOptions {
5283
*/
5384
operations: DiffTriggerOperation[];
5485

55-
/**
56-
* Destination table to track changes to.
57-
* This table is created internally.
58-
*/
59-
destination: string;
60-
6186
/**
6287
* Columns to track and report changes for.
6388
* Defaults to all columns in the source table.
89+
* Use an empty array to track only the ID and operation.
6490
*/
6591
columns?: string[];
6692

6793
/**
68-
* Optional condition to filter when the trigger should fire.
94+
* Optional condition to filter when the triggers should fire.
6995
* This is useful for only triggering on specific conditions.
7096
* For example, you can use it to only trigger on certain values in the NEW row.
7197
* Note that for PowerSync the data is stored in a JSON column named `data`.
7298
* @example
73-
* [`NEW.data.status = 'active' AND length(NEW.data.name) > 3`]
99+
* {
100+
* 'INSERT': `json_extract(NEW.data, '$.list_id') = 'abcd'`
101+
* 'UPDATE': `NEW.id = 'abcd' AND json_extract(NEW.data, '$.status') = 'active'`
102+
* 'DELETE': `json_extract(OLD.data, '$.list_id') = 'abcd'`
103+
* }
74104
*/
75-
when?: string;
105+
when?: Partial<Record<DiffTriggerOperation, string>>;
76106

77107
/**
78108
* Optional context to create the triggers in.
@@ -81,54 +111,97 @@ export interface CreateDiffTriggerOptions {
81111
hooks?: TriggerCreationHooks;
82112
}
83113

114+
/**
115+
* Options for {@link TriggerManager#createDiffTrigger}.
116+
*/
117+
export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
118+
/**
119+
* Destination table to track changes to.
120+
* This table is created internally.
121+
*/
122+
destination: string;
123+
}
124+
84125
export type TriggerRemoveCallback = () => Promise<void>;
85126

86-
export interface TriggerDiffHandlerContext {
127+
/**
128+
* Context for the onChange handler provided to {@link TriggerManager#trackTableDiff}.
129+
*/
130+
export interface TriggerDiffHandlerContext extends LockContext {
131+
/**
132+
* The name of the temporary destination table created by the trigger.
133+
*/
134+
destination_table: string;
135+
87136
/**
88137
* Allows querying the database with access to the table containing diff records.
89-
* The diff table is accessible via the `diff` accessor.
138+
* The diff table is accessible via the `DIFF` accessor.
90139
*
91140
* The `DIFF` table is of the form described in {@link TriggerManager#createDiffTrigger}
141+
* ```sql
142+
* CREATE TEMP DIFF (
143+
* id TEXT,
144+
* operation TEXT,
145+
* timestamp TEXT
146+
* value TEXT,
147+
* previous_value TEXT
148+
* );
149+
* ```
92150
*
93151
* @example
94152
* ```sql
95153
* SELECT
96154
* todos.*
97155
* FROM
98156
* DIFF
99-
* JOIN todos ON DIFF.id = todos.id
157+
* JOIN todos ON DIFF.id = todos.id
158+
* WHERE json_extract(DIFF.value, '$.status') = 'active'
100159
* ```
101160
*/
102-
getAll: <T = any>(query: string, params?: any[]) => Promise<T[]>;
103-
}
161+
withDiff: <T = any>(query: string, params?: any[]) => Promise<T[]>;
104162

105-
export interface TrackDiffOptions {
106-
/**
107-
* A source SQLite table/view to track changes for.
108-
* This should be present in the PowerSync database's schema.
109-
*/
110-
source: string;
111-
/**
112-
* SQLite Trigger WHEN condition to filter when the trigger should fire.
113-
*/
114-
filter?: string;
115163
/**
116-
* Table columns to track changes for.
117-
*/
118-
columns?: string[];
119-
/**
120-
* SQLite operations to track changes for.
164+
* Allows querying the database with access to the table containing diff records.
165+
* The diff table is accessible via the `DIFF` accessor.
166+
*
167+
* This is similar to {@link withDiff} but extracts the columns from the JSON value.
168+
* The `DIFF` table exposes the tracked table columns directly as columns. The diff meta data is available as _columns.
169+
*
170+
* ```sql
171+
* CREATE TEMP TABLE DIFF (
172+
* id TEXT,
173+
* replicated_column_1 COLUMN_TYPE,
174+
* replicated_column_2 COLUMN_TYPE,
175+
* __operation TEXT,
176+
* __timestamp TEXT,
177+
* __previous_value TEXT
178+
* );
179+
* ```
180+
*
181+
* @example
182+
* ```sql
183+
* SELECT
184+
* todos.*
185+
* FROM
186+
* DIFF
187+
* JOIN todos ON DIFF.id = todos.id
188+
* --- The todo column names are extracted from json and are available as DIFF.name
189+
* WHERE DIFF.name = 'example'
190+
* ```
121191
*/
122-
operations: DiffTriggerOperation[];
192+
withExtractedDiff: <T = any>(query: string, params?: any[]) => Promise<T[]>;
193+
}
194+
195+
/**
196+
* Options for tracking changes to a table with {@link TriggerManager#trackTableDiff}.
197+
*/
198+
export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions {
123199
/**
124200
* Handler for processing diff operations.
125201
* Automatically invoked once diff items are present.
202+
* Diff items are automatically cleared after the handler is invoked.
126203
*/
127204
onChange: (context: TriggerDiffHandlerContext) => Promise<void>;
128-
/**
129-
* Hooks into the trigger creation process.
130-
*/
131-
hooks?: TriggerCreationHooks;
132205
}
133206

134207
export interface TriggerManager {

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

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,6 @@ export class TriggerManagerImpl implements TriggerManager {
5454
throw new Error('At least one operation must be specified for the trigger.');
5555
}
5656

57-
if (columns && columns.length == 0) {
58-
throw new Error('At least one column must be specified for the trigger.');
59-
}
60-
6157
/**
6258
* Allow specifying the View name as the source.
6359
* We can lookup the internal table name from the schema.
@@ -67,20 +63,36 @@ export class TriggerManagerImpl implements TriggerManager {
6763
throw new Error(`Source table or view "${source}" not found in the schema.`);
6864
}
6965

66+
const replicatedColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
67+
7068
const internalSource = sourceDefinition.internalName;
7169

72-
/**
73-
* When is a tuple of the query and the parameters.
74-
*/
75-
const whenCondition = when ? `WHEN ${when}` : '';
70+
const invalidWhenOperations =
71+
when && Object.keys(when).filter((operation) => operations.includes(operation as DiffTriggerOperation) == false);
72+
if (invalidWhenOperations?.length) {
73+
throw new Error(
74+
`Invalid 'when' conditions provided for operations: ${invalidWhenOperations.join(', ')}. ` +
75+
`These operations are not included in the 'operations' array: ${operations.join(', ')}.`
76+
);
77+
}
78+
79+
const whenConditions = Object.fromEntries(
80+
Object.values(DiffTriggerOperation).map((operation) => [
81+
operation,
82+
when?.[operation] ? `WHEN ${when[operation]}` : ''
83+
])
84+
) as Record<DiffTriggerOperation, string>;
7685

7786
const triggerIds: string[] = [];
7887

7988
const id = await this.getUUID();
8089

90+
/**
91+
* We default to replicating all columns if no columns array is provided.
92+
*/
8193
const jsonFragment = (source: 'NEW' | 'OLD' = 'NEW') =>
8294
columns
83-
? `json_object(${columns.map((col) => `'${col}', json_extract(${source}.data, '$.${col}')`).join(', ')})`
95+
? `json_object(${replicatedColumns.map((col) => `'${col}', json_extract(${source}.data, '$.${col}')`).join(', ')})`
8496
: `${source}.data`;
8597

8698
/**
@@ -112,7 +124,9 @@ export class TriggerManagerImpl implements TriggerManager {
112124
triggerIds.push(insertTriggerId);
113125

114126
await tx.execute(/* sql */ `
115-
CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenCondition} BEGIN
127+
CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenConditions[
128+
DiffTriggerOperation.INSERT
129+
]} BEGIN
116130
INSERT INTO
117131
${destination} (id, operation, timestamp, value)
118132
VALUES
@@ -133,7 +147,7 @@ export class TriggerManagerImpl implements TriggerManager {
133147

134148
await tx.execute(/* sql */ `
135149
CREATE TEMP TRIGGER ${updateTriggerId} AFTER
136-
UPDATE ON ${internalSource} ${whenCondition} BEGIN
150+
UPDATE ON ${internalSource} ${whenConditions[DiffTriggerOperation.UPDATE]} BEGIN
137151
INSERT INTO
138152
${destination} (id, operation, timestamp, value, previous_value)
139153
VALUES
@@ -155,7 +169,9 @@ export class TriggerManagerImpl implements TriggerManager {
155169

156170
// Create delete trigger for basic JSON
157171
await tx.execute(/* sql */ `
158-
CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenCondition} BEGIN
172+
CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenConditions[
173+
DiffTriggerOperation.DELETE
174+
]} BEGIN
159175
INSERT INTO
160176
${destination} (id, operation, timestamp)
161177
VALUES
@@ -180,10 +196,23 @@ export class TriggerManagerImpl implements TriggerManager {
180196
}
181197

182198
async trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback> {
183-
const { source, filter, columns, operations, hooks } = options;
199+
const { source, when, columns, operations, hooks } = options;
184200

185201
await this.db.waitForReady();
186202

203+
/**
204+
* Allow specifying the View name as the source.
205+
* We can lookup the internal table name from the schema.
206+
*/
207+
const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
208+
if (!sourceDefinition) {
209+
throw new Error(`Source table or view "${source}" not found in the schema.`);
210+
}
211+
212+
// The columns to present in the onChange context methods.
213+
// If no array is provided, we use all columns from the source table.
214+
const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
215+
187216
const id = await this.getUUID();
188217
const destination = `ps_temp_track_${source}_${id}`;
189218

@@ -201,7 +230,9 @@ export class TriggerManagerImpl implements TriggerManager {
201230
// destination table consistent.
202231
await this.db.writeTransaction(async (tx) => {
203232
const callbackResult = await options.onChange({
204-
getAll: async <T>(query, params) => {
233+
...tx,
234+
destination_table: destination,
235+
withDiff: async <T>(query, params) => {
205236
// Wrap the query to expose the destination table
206237
const wrappedQuery = /* sql */ `
207238
WITH
@@ -213,6 +244,23 @@ export class TriggerManagerImpl implements TriggerManager {
213244
) ${query}
214245
`;
215246
return tx.getAll<T>(wrappedQuery, params);
247+
},
248+
withExtractedDiff: async <T>(query, params) => {
249+
// Wrap the query to expose the destination table
250+
const wrappedQuery = /* sql */ `
251+
WITH
252+
DIFF AS (
253+
SELECT
254+
id,
255+
${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},
256+
operation as __operation,
257+
timestamp as __timestamp,
258+
previous_value as __previous_value
259+
FROM
260+
${destination}
261+
) ${query}
262+
`;
263+
return tx.getAll<T>(wrappedQuery, params);
216264
}
217265
});
218266

@@ -229,9 +277,9 @@ export class TriggerManagerImpl implements TriggerManager {
229277
const removeTrigger = await this.createDiffTrigger({
230278
source,
231279
destination,
232-
columns,
280+
columns: contextColumns,
233281
operations,
234-
when: filter,
282+
when,
235283
hooks
236284
});
237285

0 commit comments

Comments
 (0)