Skip to content

Commit 3af4a2c

Browse files
feat: operation_id for trigger based diffs (#770)
1 parent 4c66487 commit 3af4a2c

File tree

4 files changed

+195
-18
lines changed

4 files changed

+195
-18
lines changed

.changeset/clever-keys-learn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/common': minor
3+
---
4+
5+
Added auto incrementing operation_id column to Trigger based diff temporary tables and results. This allows for better operation ordering compared to using the previous timestamp column.

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

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,11 @@ export enum DiffTriggerOperation {
1414
* @experimental
1515
* Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
1616
* This is the base record structure for all diff records.
17+
*
18+
* @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by default SQLite database queries.
19+
* Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
1720
*/
18-
export interface BaseTriggerDiffRecord {
21+
export interface BaseTriggerDiffRecord<TOperationId extends string | number = number> {
1922
/**
2023
* The modified row's `id` column value.
2124
*/
@@ -24,6 +27,13 @@ export interface BaseTriggerDiffRecord {
2427
* The operation performed which created this record.
2528
*/
2629
operation: DiffTriggerOperation;
30+
/**
31+
* Auto-incrementing primary key for the operation.
32+
* Defaults to number as returned by database queries (wa-sqlite returns lower 32 bits).
33+
* Can be string for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
34+
*/
35+
operation_id: TOperationId;
36+
2737
/**
2838
* Time the change operation was recorded.
2939
* This is in ISO 8601 format, e.g. `2023-10-01T12:00:00.000Z`.
@@ -37,7 +47,8 @@ export interface BaseTriggerDiffRecord {
3747
* This record contains the new value and optionally the previous value.
3848
* Values are stored as JSON strings.
3949
*/
40-
export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
50+
export interface TriggerDiffUpdateRecord<TOperationId extends string | number = number>
51+
extends BaseTriggerDiffRecord<TOperationId> {
4152
operation: DiffTriggerOperation.UPDATE;
4253
/**
4354
* The updated state of the row in JSON string format.
@@ -54,7 +65,8 @@ export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
5465
* Represents a diff record for a SQLite INSERT operation.
5566
* This record contains the new value represented as a JSON string.
5667
*/
57-
export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
68+
export interface TriggerDiffInsertRecord<TOperationId extends string | number = number>
69+
extends BaseTriggerDiffRecord<TOperationId> {
5870
operation: DiffTriggerOperation.INSERT;
5971
/**
6072
* The value of the row, at the time of INSERT, in JSON string format.
@@ -67,7 +79,8 @@ export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
6779
* Represents a diff record for a SQLite DELETE operation.
6880
* This record contains the new value represented as a JSON string.
6981
*/
70-
export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
82+
export interface TriggerDiffDeleteRecord<TOperationId extends string | number = number>
83+
extends BaseTriggerDiffRecord<TOperationId> {
7184
operation: DiffTriggerOperation.DELETE;
7285
/**
7386
* The value of the row, before the DELETE operation, in JSON string format.
@@ -82,27 +95,53 @@ export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
8295
*
8396
* Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withDiff} will return records
8497
* with the structure of this type.
98+
*
99+
* @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by database queries.
100+
* Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
101+
*
85102
* @example
86103
* ```typescript
104+
* // Default: operation_id is number
87105
* const diffs = await context.withDiff<TriggerDiffRecord>('SELECT * FROM DIFF');
88-
* diff.forEach(diff => console.log(diff.operation, diff.timestamp, JSON.parse(diff.value)))
106+
*
107+
* // With string operation_id for full precision
108+
* const diffsWithString = await context.withDiff<TriggerDiffRecord<string>>(
109+
* 'SELECT * FROM DIFF',
110+
* undefined,
111+
* { castOperationIdAsText: true }
112+
* );
89113
* ```
90114
*/
91-
export type TriggerDiffRecord = TriggerDiffUpdateRecord | TriggerDiffInsertRecord | TriggerDiffDeleteRecord;
115+
export type TriggerDiffRecord<TOperationId extends string | number = number> =
116+
| TriggerDiffUpdateRecord<TOperationId>
117+
| TriggerDiffInsertRecord<TOperationId>
118+
| TriggerDiffDeleteRecord<TOperationId>;
92119

93120
/**
94121
* @experimental
95122
* Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withExtractedDiff} will return records
96123
* with the tracked columns extracted from the JSON value.
97124
* This type represents the structure of such records.
125+
*
126+
* @template T - The type for the extracted columns from the tracked JSON value.
127+
* @template TOperationId - The type for `operation_id`. Defaults to `number` as returned by database queries.
128+
* Use `string` for full 64-bit precision when using `{ castOperationIdAsText: true }` option.
129+
*
98130
* @example
99131
* ```typescript
132+
* // Default: operation_id is number
100133
* const diffs = await context.withExtractedDiff<ExtractedTriggerDiffRecord<{id: string, name: string}>>('SELECT * FROM DIFF');
101-
* diff.forEach(diff => console.log(diff.__operation, diff.__timestamp, diff.columnName))
134+
*
135+
* // With string operation_id for full precision
136+
* const diffsWithString = await context.withExtractedDiff<ExtractedTriggerDiffRecord<{id: string, name: string}, string>>(
137+
* 'SELECT * FROM DIFF',
138+
* undefined,
139+
* { castOperationIdAsText: true }
140+
* );
102141
* ```
103142
*/
104-
export type ExtractedTriggerDiffRecord<T> = T & {
105-
[K in keyof Omit<BaseTriggerDiffRecord, 'id'> as `__${string & K}`]: TriggerDiffRecord[K];
143+
export type ExtractedTriggerDiffRecord<T, TOperationId extends string | number = number> = T & {
144+
[K in keyof Omit<BaseTriggerDiffRecord<TOperationId>, 'id'> as `__${string & K}`]: TriggerDiffRecord<TOperationId>[K];
106145
} & {
107146
__previous_value?: string;
108147
};
@@ -183,6 +222,21 @@ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
183222
*/
184223
export type TriggerRemoveCallback = () => Promise<void>;
185224

225+
/**
226+
* @experimental
227+
* Options for {@link TriggerDiffHandlerContext#withDiff}.
228+
*/
229+
export interface WithDiffOptions {
230+
/**
231+
* If true, casts `operation_id` as TEXT in the internal CTE to preserve full 64-bit precision.
232+
* Use this when you need to ensure `operation_id` is treated as a string to avoid precision loss
233+
* for values exceeding JavaScript's Number.MAX_SAFE_INTEGER.
234+
*
235+
* When enabled, use {@link TriggerDiffRecord}<string> to type the result correctly.
236+
*/
237+
castOperationIdAsText?: boolean;
238+
}
239+
186240
/**
187241
* @experimental
188242
* Context for the `onChange` handler provided to {@link TriggerManager#trackTableDiff}.
@@ -200,9 +254,10 @@ export interface TriggerDiffHandlerContext extends LockContext {
200254
* The `DIFF` table is of the form described in {@link TriggerManager#createDiffTrigger}
201255
* ```sql
202256
* CREATE TEMP DIFF (
257+
* operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
203258
* id TEXT,
204259
* operation TEXT,
205-
* timestamp TEXT
260+
* timestamp TEXT,
206261
* value TEXT,
207262
* previous_value TEXT
208263
* );
@@ -222,8 +277,19 @@ export interface TriggerDiffHandlerContext extends LockContext {
222277
* JOIN todos ON DIFF.id = todos.id
223278
* WHERE json_extract(DIFF.value, '$.status') = 'active'
224279
* ```
280+
*
281+
* @example
282+
* ```typescript
283+
* // With operation_id cast as TEXT for full precision
284+
* const diffs = await context.withDiff<TriggerDiffRecord<string>>(
285+
* 'SELECT * FROM DIFF',
286+
* undefined,
287+
* { castOperationIdAsText: true }
288+
* );
289+
* // diffs[0].operation_id is now typed as string
290+
* ```
225291
*/
226-
withDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>) => Promise<T[]>;
292+
withDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>, options?: WithDiffOptions) => Promise<T[]>;
227293

228294
/**
229295
* Allows querying the database with access to the table containing diff records.
@@ -292,9 +358,10 @@ export interface TriggerManager {
292358
*
293359
* ```sql
294360
* CREATE TEMP TABLE ${destination} (
361+
* operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
295362
* id TEXT,
296363
* operation TEXT,
297-
* timestamp TEXT
364+
* timestamp TEXT,
298365
* value TEXT,
299366
* previous_value TEXT
300367
* );

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
DiffTriggerOperation,
88
TrackDiffOptions,
99
TriggerManager,
10-
TriggerRemoveCallback
10+
TriggerRemoveCallback,
11+
WithDiffOptions
1112
} from './TriggerManager.js';
1213

1314
export type TriggerManagerImplOptions = {
@@ -117,6 +118,7 @@ export class TriggerManagerImpl implements TriggerManager {
117118
await hooks?.beforeCreate?.(tx);
118119
await tx.execute(/* sql */ `
119120
CREATE TEMP TABLE ${destination} (
121+
operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
120122
id TEXT,
121123
operation TEXT,
122124
timestamp TEXT,
@@ -243,17 +245,20 @@ export class TriggerManagerImpl implements TriggerManager {
243245
const callbackResult = await options.onChange({
244246
...tx,
245247
destinationTable: destination,
246-
withDiff: async <T>(query, params) => {
248+
withDiff: async <T>(query, params, options?: WithDiffOptions) => {
247249
// Wrap the query to expose the destination table
250+
const operationIdSelect = options?.castOperationIdAsText
251+
? 'id, operation, CAST(operation_id AS TEXT) as operation_id, timestamp, value, previous_value'
252+
: '*';
248253
const wrappedQuery = /* sql */ `
249254
WITH
250255
DIFF AS (
251256
SELECT
252-
*
257+
${operationIdSelect}
253258
FROM
254259
${destination}
255260
ORDER BY
256-
timestamp ASC
261+
operation_id ASC
257262
) ${query}
258263
`;
259264
return tx.getAll<T>(wrappedQuery, params);
@@ -267,13 +272,14 @@ export class TriggerManagerImpl implements TriggerManager {
267272
id,
268273
${contextColumns.length > 0
269274
? `${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},`
270-
: ''} operation as __operation,
275+
: ''} operation_id as __operation_id,
276+
operation as __operation,
271277
timestamp as __timestamp,
272278
previous_value as __previous_value
273279
FROM
274280
${destination}
275281
ORDER BY
276-
__timestamp ASC
282+
__operation_id ASC
277283
) ${query}
278284
`;
279285
return tx.getAll<T>(wrappedQuery, params);

packages/node/tests/trigger.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ describe('Triggers', () => {
6565
() => {
6666
expect(results.length).toEqual(3);
6767

68+
// Check that operation_id values exist and are numbers
69+
expect(results[0].operation_id).toBeDefined();
70+
expect(typeof results[0].operation_id).toBe('number');
71+
expect(results[0].operation_id).eq(1);
72+
expect(results[1].operation_id).toBeDefined();
73+
expect(results[1].operation_id).eq(2);
74+
6875
expect(results[0].operation).toEqual(DiffTriggerOperation.INSERT);
6976
const parsedInsert = JSON.parse(results[0].value);
7077
// only the filtered columns should be tracked
@@ -605,4 +612,96 @@ describe('Triggers', () => {
605612
expect(changes[4].columnB).toBeUndefined();
606613
expect(changes[4].__previous_value).toBeNull();
607614
});
615+
616+
databaseTest('Should cast operation_id as string with withDiff option', async ({ database }) => {
617+
const results: TriggerDiffRecord<string>[] = [];
618+
619+
await database.triggers.trackTableDiff({
620+
source: 'todos',
621+
columns: ['content'],
622+
when: {
623+
[DiffTriggerOperation.INSERT]: 'TRUE'
624+
},
625+
onChange: async (context) => {
626+
const diffs = await context.withDiff<TriggerDiffRecord<string>>('SELECT * FROM DIFF', undefined, {
627+
castOperationIdAsText: true
628+
});
629+
results.push(...diffs);
630+
}
631+
});
632+
633+
await database.execute('INSERT INTO todos (id, content) VALUES (uuid(), ?);', ['test 1']);
634+
await database.execute('INSERT INTO todos (id, content) VALUES (uuid(), ?);', ['test 2']);
635+
636+
await vi.waitFor(
637+
() => {
638+
expect(results.length).toEqual(2);
639+
// Check that operation_id is a string when castOperationIdAsText is enabled
640+
expect(typeof results[0].operation_id).toBe('string');
641+
expect(typeof results[1].operation_id).toBe('string');
642+
// Should be incrementing
643+
expect(Number.parseInt(results[0].operation_id)).toBeLessThan(Number.parseInt(results[1].operation_id));
644+
},
645+
{ timeout: 1000 }
646+
);
647+
});
648+
649+
databaseTest('Should report changes in transaction order using operation_id', async ({ database }) => {
650+
const results: TriggerDiffRecord[] = [];
651+
652+
await database.triggers.trackTableDiff({
653+
source: 'todos',
654+
columns: ['content'],
655+
when: {
656+
[DiffTriggerOperation.INSERT]: 'TRUE',
657+
[DiffTriggerOperation.UPDATE]: 'TRUE',
658+
[DiffTriggerOperation.DELETE]: 'TRUE'
659+
},
660+
onChange: async (context) => {
661+
const diffs = await context.withDiff<TriggerDiffRecord>('SELECT * FROM DIFF');
662+
results.push(...diffs);
663+
}
664+
});
665+
666+
// Perform multiple operations in a single transaction
667+
const contents = ['first', 'second', 'third', 'fourth'];
668+
await database.writeLock(async (tx) => {
669+
// Insert first todo
670+
await tx.execute('INSERT INTO todos (id, content) VALUES (uuid(), ?);', [contents[0]]);
671+
// Insert second todo
672+
await tx.execute('INSERT INTO todos (id, content) VALUES (uuid(), ?);', [contents[1]]);
673+
// Update first todo
674+
await tx.execute('UPDATE todos SET content = ? WHERE content = ?;', [contents[2], contents[0]]);
675+
// Delete second todo
676+
await tx.execute('DELETE FROM todos WHERE content = ?;', [contents[1]]);
677+
});
678+
679+
await vi.waitFor(
680+
() => {
681+
expect(results.length).toEqual(4);
682+
683+
// Verify operation_ids are incrementing (ensuring order)
684+
expect(results[0].operation_id).toBeLessThan(results[1].operation_id);
685+
expect(results[1].operation_id).toBeLessThan(results[2].operation_id);
686+
expect(results[2].operation_id).toBeLessThan(results[3].operation_id);
687+
688+
// Verify operations are in the correct order
689+
expect(results[0].operation).toBe(DiffTriggerOperation.INSERT);
690+
expect(JSON.parse(results[0].value).content).toBe(contents[0]);
691+
692+
expect(results[1].operation).toBe(DiffTriggerOperation.INSERT);
693+
expect(JSON.parse(results[1].value).content).toBe(contents[1]);
694+
695+
expect(results[2].operation).toBe(DiffTriggerOperation.UPDATE);
696+
if (results[2].operation === DiffTriggerOperation.UPDATE) {
697+
expect(JSON.parse(results[2].value).content).toBe(contents[2]);
698+
expect(JSON.parse(results[2].previous_value).content).toBe(contents[0]);
699+
}
700+
701+
expect(results[3].operation).toBe(DiffTriggerOperation.DELETE);
702+
expect(JSON.parse(results[3].value).content).toBe(contents[1]);
703+
},
704+
{ timeout: 1000 }
705+
);
706+
});
608707
});

0 commit comments

Comments
 (0)