@powersync/common 1.41.0 → 1.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/dist/bundle.cjs +10809 -22
  2. package/dist/bundle.cjs.map +1 -0
  3. package/dist/bundle.mjs +10730 -22
  4. package/dist/bundle.mjs.map +1 -0
  5. package/dist/bundle.node.cjs +10809 -0
  6. package/dist/bundle.node.cjs.map +1 -0
  7. package/dist/bundle.node.mjs +10730 -0
  8. package/dist/bundle.node.mjs.map +1 -0
  9. package/dist/index.d.cts +5 -0
  10. package/lib/client/AbstractPowerSyncDatabase.js +1 -0
  11. package/lib/client/AbstractPowerSyncDatabase.js.map +1 -0
  12. package/lib/client/AbstractPowerSyncOpenFactory.js +1 -0
  13. package/lib/client/AbstractPowerSyncOpenFactory.js.map +1 -0
  14. package/lib/client/ConnectionManager.js +1 -0
  15. package/lib/client/ConnectionManager.js.map +1 -0
  16. package/lib/client/CustomQuery.js +1 -0
  17. package/lib/client/CustomQuery.js.map +1 -0
  18. package/lib/client/Query.js +1 -0
  19. package/lib/client/Query.js.map +1 -0
  20. package/lib/client/SQLOpenFactory.js +1 -0
  21. package/lib/client/SQLOpenFactory.js.map +1 -0
  22. package/lib/client/compilableQueryWatch.js +1 -0
  23. package/lib/client/compilableQueryWatch.js.map +1 -0
  24. package/lib/client/connection/PowerSyncBackendConnector.js +1 -0
  25. package/lib/client/connection/PowerSyncBackendConnector.js.map +1 -0
  26. package/lib/client/connection/PowerSyncCredentials.js +1 -0
  27. package/lib/client/connection/PowerSyncCredentials.js.map +1 -0
  28. package/lib/client/constants.js +1 -0
  29. package/lib/client/constants.js.map +1 -0
  30. package/lib/client/runOnSchemaChange.js +1 -0
  31. package/lib/client/runOnSchemaChange.js.map +1 -0
  32. package/lib/client/sync/bucket/BucketStorageAdapter.js +1 -0
  33. package/lib/client/sync/bucket/BucketStorageAdapter.js.map +1 -0
  34. package/lib/client/sync/bucket/CrudBatch.js +1 -0
  35. package/lib/client/sync/bucket/CrudBatch.js.map +1 -0
  36. package/lib/client/sync/bucket/CrudEntry.js +1 -0
  37. package/lib/client/sync/bucket/CrudEntry.js.map +1 -0
  38. package/lib/client/sync/bucket/CrudTransaction.js +1 -0
  39. package/lib/client/sync/bucket/CrudTransaction.js.map +1 -0
  40. package/lib/client/sync/bucket/OpType.js +1 -0
  41. package/lib/client/sync/bucket/OpType.js.map +1 -0
  42. package/lib/client/sync/bucket/OplogEntry.js +1 -0
  43. package/lib/client/sync/bucket/OplogEntry.js.map +1 -0
  44. package/lib/client/sync/bucket/SqliteBucketStorage.js +1 -0
  45. package/lib/client/sync/bucket/SqliteBucketStorage.js.map +1 -0
  46. package/lib/client/sync/bucket/SyncDataBatch.js +1 -0
  47. package/lib/client/sync/bucket/SyncDataBatch.js.map +1 -0
  48. package/lib/client/sync/bucket/SyncDataBucket.js +1 -0
  49. package/lib/client/sync/bucket/SyncDataBucket.js.map +1 -0
  50. package/lib/client/sync/stream/AbstractRemote.d.ts +5 -0
  51. package/lib/client/sync/stream/AbstractRemote.js +9 -2
  52. package/lib/client/sync/stream/AbstractRemote.js.map +1 -0
  53. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +1 -0
  54. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -0
  55. package/lib/client/sync/stream/WebsocketClientTransport.js +1 -0
  56. package/lib/client/sync/stream/WebsocketClientTransport.js.map +1 -0
  57. package/lib/client/sync/stream/core-instruction.js +1 -0
  58. package/lib/client/sync/stream/core-instruction.js.map +1 -0
  59. package/lib/client/sync/stream/streaming-sync-types.js +1 -0
  60. package/lib/client/sync/stream/streaming-sync-types.js.map +1 -0
  61. package/lib/client/sync/sync-streams.js +1 -0
  62. package/lib/client/sync/sync-streams.js.map +1 -0
  63. package/lib/client/triggers/TriggerManager.js +1 -0
  64. package/lib/client/triggers/TriggerManager.js.map +1 -0
  65. package/lib/client/triggers/TriggerManagerImpl.js +1 -0
  66. package/lib/client/triggers/TriggerManagerImpl.js.map +1 -0
  67. package/lib/client/triggers/sanitizeSQL.js +1 -0
  68. package/lib/client/triggers/sanitizeSQL.js.map +1 -0
  69. package/lib/client/watched/GetAllQuery.js +1 -0
  70. package/lib/client/watched/GetAllQuery.js.map +1 -0
  71. package/lib/client/watched/WatchedQuery.js +1 -0
  72. package/lib/client/watched/WatchedQuery.js.map +1 -0
  73. package/lib/client/watched/processors/AbstractQueryProcessor.js +1 -0
  74. package/lib/client/watched/processors/AbstractQueryProcessor.js.map +1 -0
  75. package/lib/client/watched/processors/DifferentialQueryProcessor.js +1 -0
  76. package/lib/client/watched/processors/DifferentialQueryProcessor.js.map +1 -0
  77. package/lib/client/watched/processors/OnChangeQueryProcessor.js +1 -0
  78. package/lib/client/watched/processors/OnChangeQueryProcessor.js.map +1 -0
  79. package/lib/client/watched/processors/comparators.js +1 -0
  80. package/lib/client/watched/processors/comparators.js.map +1 -0
  81. package/lib/db/DBAdapter.js +1 -0
  82. package/lib/db/DBAdapter.js.map +1 -0
  83. package/lib/db/crud/SyncProgress.js +1 -0
  84. package/lib/db/crud/SyncProgress.js.map +1 -0
  85. package/lib/db/crud/SyncStatus.js +1 -0
  86. package/lib/db/crud/SyncStatus.js.map +1 -0
  87. package/lib/db/crud/UploadQueueStatus.js +1 -0
  88. package/lib/db/crud/UploadQueueStatus.js.map +1 -0
  89. package/lib/db/schema/Column.js +1 -0
  90. package/lib/db/schema/Column.js.map +1 -0
  91. package/lib/db/schema/Index.js +1 -0
  92. package/lib/db/schema/Index.js.map +1 -0
  93. package/lib/db/schema/IndexedColumn.js +1 -0
  94. package/lib/db/schema/IndexedColumn.js.map +1 -0
  95. package/lib/db/schema/RawTable.js +1 -0
  96. package/lib/db/schema/RawTable.js.map +1 -0
  97. package/lib/db/schema/Schema.js +1 -0
  98. package/lib/db/schema/Schema.js.map +1 -0
  99. package/lib/db/schema/Table.js +1 -0
  100. package/lib/db/schema/Table.js.map +1 -0
  101. package/lib/db/schema/TableV2.js +1 -0
  102. package/lib/db/schema/TableV2.js.map +1 -0
  103. package/lib/index.js +1 -0
  104. package/lib/index.js.map +1 -0
  105. package/lib/types/types.js +1 -0
  106. package/lib/types/types.js.map +1 -0
  107. package/lib/utils/AbortOperation.js +1 -0
  108. package/lib/utils/AbortOperation.js.map +1 -0
  109. package/lib/utils/BaseObserver.js +1 -0
  110. package/lib/utils/BaseObserver.js.map +1 -0
  111. package/lib/utils/ControlledExecutor.js +1 -0
  112. package/lib/utils/ControlledExecutor.js.map +1 -0
  113. package/lib/utils/DataStream.js +1 -0
  114. package/lib/utils/DataStream.js.map +1 -0
  115. package/lib/utils/Logger.js +1 -0
  116. package/lib/utils/Logger.js.map +1 -0
  117. package/lib/utils/MetaBaseObserver.js +1 -0
  118. package/lib/utils/MetaBaseObserver.js.map +1 -0
  119. package/lib/utils/async.js +1 -0
  120. package/lib/utils/async.js.map +1 -0
  121. package/lib/utils/mutex.js +1 -0
  122. package/lib/utils/mutex.js.map +1 -0
  123. package/lib/utils/parseQuery.js +1 -0
  124. package/lib/utils/parseQuery.js.map +1 -0
  125. package/package.json +23 -15
  126. package/src/client/AbstractPowerSyncDatabase.ts +1343 -0
  127. package/src/client/AbstractPowerSyncOpenFactory.ts +39 -0
  128. package/src/client/ConnectionManager.ts +402 -0
  129. package/src/client/CustomQuery.ts +56 -0
  130. package/src/client/Query.ts +106 -0
  131. package/src/client/SQLOpenFactory.ts +55 -0
  132. package/src/client/compilableQueryWatch.ts +55 -0
  133. package/src/client/connection/PowerSyncBackendConnector.ts +25 -0
  134. package/src/client/connection/PowerSyncCredentials.ts +5 -0
  135. package/src/client/constants.ts +1 -0
  136. package/src/client/runOnSchemaChange.ts +31 -0
  137. package/src/client/sync/bucket/BucketStorageAdapter.ts +118 -0
  138. package/src/client/sync/bucket/CrudBatch.ts +21 -0
  139. package/src/client/sync/bucket/CrudEntry.ts +172 -0
  140. package/src/client/sync/bucket/CrudTransaction.ts +21 -0
  141. package/src/client/sync/bucket/OpType.ts +23 -0
  142. package/src/client/sync/bucket/OplogEntry.ts +50 -0
  143. package/src/client/sync/bucket/SqliteBucketStorage.ts +395 -0
  144. package/src/client/sync/bucket/SyncDataBatch.ts +11 -0
  145. package/src/client/sync/bucket/SyncDataBucket.ts +49 -0
  146. package/src/client/sync/stream/AbstractRemote.ts +626 -0
  147. package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +1258 -0
  148. package/src/client/sync/stream/WebsocketClientTransport.ts +80 -0
  149. package/src/client/sync/stream/core-instruction.ts +99 -0
  150. package/src/client/sync/stream/streaming-sync-types.ts +205 -0
  151. package/src/client/sync/sync-streams.ts +107 -0
  152. package/src/client/triggers/TriggerManager.ts +384 -0
  153. package/src/client/triggers/TriggerManagerImpl.ts +314 -0
  154. package/src/client/triggers/sanitizeSQL.ts +66 -0
  155. package/src/client/watched/GetAllQuery.ts +46 -0
  156. package/src/client/watched/WatchedQuery.ts +121 -0
  157. package/src/client/watched/processors/AbstractQueryProcessor.ts +226 -0
  158. package/src/client/watched/processors/DifferentialQueryProcessor.ts +305 -0
  159. package/src/client/watched/processors/OnChangeQueryProcessor.ts +122 -0
  160. package/src/client/watched/processors/comparators.ts +57 -0
  161. package/src/db/DBAdapter.ts +134 -0
  162. package/src/db/crud/SyncProgress.ts +100 -0
  163. package/src/db/crud/SyncStatus.ts +308 -0
  164. package/src/db/crud/UploadQueueStatus.ts +20 -0
  165. package/src/db/schema/Column.ts +60 -0
  166. package/src/db/schema/Index.ts +39 -0
  167. package/src/db/schema/IndexedColumn.ts +42 -0
  168. package/src/db/schema/RawTable.ts +67 -0
  169. package/src/db/schema/Schema.ts +76 -0
  170. package/src/db/schema/Table.ts +359 -0
  171. package/src/db/schema/TableV2.ts +9 -0
  172. package/src/index.ts +52 -0
  173. package/src/types/types.ts +9 -0
  174. package/src/utils/AbortOperation.ts +17 -0
  175. package/src/utils/BaseObserver.ts +41 -0
  176. package/src/utils/ControlledExecutor.ts +72 -0
  177. package/src/utils/DataStream.ts +211 -0
  178. package/src/utils/Logger.ts +47 -0
  179. package/src/utils/MetaBaseObserver.ts +81 -0
  180. package/src/utils/async.ts +61 -0
  181. package/src/utils/mutex.ts +34 -0
  182. package/src/utils/parseQuery.ts +25 -0
@@ -0,0 +1,384 @@
1
+ import { LockContext } from '../../db/DBAdapter.js';
2
+
3
+ /**
4
+ * SQLite operations to track changes for with {@link TriggerManager}
5
+ * @experimental
6
+ */
7
+ export enum DiffTriggerOperation {
8
+ INSERT = 'INSERT',
9
+ UPDATE = 'UPDATE',
10
+ DELETE = 'DELETE'
11
+ }
12
+
13
+ /**
14
+ * @experimental
15
+ * Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
16
+ * This is the base record structure for all diff records.
17
+ */
18
+ export interface BaseTriggerDiffRecord {
19
+ /**
20
+ * The modified row's `id` column value.
21
+ */
22
+ id: string;
23
+ /**
24
+ * The operation performed which created this record.
25
+ */
26
+ operation: DiffTriggerOperation;
27
+ /**
28
+ * Time the change operation was recorded.
29
+ * This is in ISO 8601 format, e.g. `2023-10-01T12:00:00.000Z`.
30
+ */
31
+ timestamp: string;
32
+ }
33
+
34
+ /**
35
+ * @experimental
36
+ * Represents a diff record for a SQLite UPDATE operation.
37
+ * This record contains the new value and optionally the previous value.
38
+ * Values are stored as JSON strings.
39
+ */
40
+ export interface TriggerDiffUpdateRecord extends BaseTriggerDiffRecord {
41
+ operation: DiffTriggerOperation.UPDATE;
42
+ /**
43
+ * The updated state of the row in JSON string format.
44
+ */
45
+ value: string;
46
+ /**
47
+ * The previous value of the row in JSON string format.
48
+ */
49
+ previous_value: string;
50
+ }
51
+
52
+ /**
53
+ * @experimental
54
+ * Represents a diff record for a SQLite INSERT operation.
55
+ * This record contains the new value represented as a JSON string.
56
+ */
57
+ export interface TriggerDiffInsertRecord extends BaseTriggerDiffRecord {
58
+ operation: DiffTriggerOperation.INSERT;
59
+ /**
60
+ * The value of the row, at the time of INSERT, in JSON string format.
61
+ */
62
+ value: string;
63
+ }
64
+
65
+ /**
66
+ * @experimental
67
+ * Represents a diff record for a SQLite DELETE operation.
68
+ * This record contains the new value represented as a JSON string.
69
+ */
70
+ export interface TriggerDiffDeleteRecord extends BaseTriggerDiffRecord {
71
+ operation: DiffTriggerOperation.DELETE;
72
+ /**
73
+ * The value of the row, before the DELETE operation, in JSON string format.
74
+ */
75
+ value: string;
76
+ }
77
+
78
+ /**
79
+ * @experimental
80
+ * Diffs created by {@link TriggerManager#createDiffTrigger} are stored in a temporary table.
81
+ * This is the record structure for all diff records.
82
+ *
83
+ * Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withDiff} will return records
84
+ * with the structure of this type.
85
+ * @example
86
+ * ```typescript
87
+ * const diffs = await context.withDiff<TriggerDiffRecord>('SELECT * FROM DIFF');
88
+ * diff.forEach(diff => console.log(diff.operation, diff.timestamp, JSON.parse(diff.value)))
89
+ * ```
90
+ */
91
+ export type TriggerDiffRecord = TriggerDiffUpdateRecord | TriggerDiffInsertRecord | TriggerDiffDeleteRecord;
92
+
93
+ /**
94
+ * @experimental
95
+ * Querying the DIFF table directly with {@link TriggerDiffHandlerContext#withExtractedDiff} will return records
96
+ * with the tracked columns extracted from the JSON value.
97
+ * This type represents the structure of such records.
98
+ * @example
99
+ * ```typescript
100
+ * 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))
102
+ * ```
103
+ */
104
+ export type ExtractedTriggerDiffRecord<T> = T & {
105
+ [K in keyof Omit<BaseTriggerDiffRecord, 'id'> as `__${string & K}`]: TriggerDiffRecord[K];
106
+ } & {
107
+ __previous_value?: string;
108
+ };
109
+
110
+ /**
111
+ * @experimental
112
+ * Hooks used in the creation of a table diff trigger.
113
+ */
114
+ export interface TriggerCreationHooks {
115
+ /**
116
+ * Executed inside a write lock before the trigger is created.
117
+ */
118
+ beforeCreate?: (context: LockContext) => Promise<void>;
119
+ }
120
+
121
+ /**
122
+ * Common interface for options used in creating a diff trigger.
123
+ */
124
+
125
+ interface BaseCreateDiffTriggerOptions {
126
+ /**
127
+ * PowerSync source table/view to trigger and track changes from.
128
+ * This should be present in the PowerSync database's schema.
129
+ */
130
+ source: string;
131
+
132
+ /**
133
+ * Columns to track and report changes for.
134
+ * Defaults to all columns in the source table.
135
+ * Use an empty array to track only the ID and operation.
136
+ */
137
+ columns?: string[];
138
+
139
+ /**
140
+ * Condition to filter when the triggers should fire.
141
+ * This corresponds to a SQLite [WHEN](https://sqlite.org/lang_createtrigger.html) clause in the trigger body.
142
+ * This is useful for only triggering on specific conditions.
143
+ * For example, you can use it to only trigger on certain values in the NEW row.
144
+ * Note that for PowerSync the row data is stored in a JSON column named `data`.
145
+ * The row id is available in the `id` column.
146
+ *
147
+ * NB! The WHEN clauses here are added directly to the SQLite trigger creation SQL.
148
+ * Any user input strings here should be sanitized externally. The {@link when} string template function performs
149
+ * some basic sanitization, extra external sanitization is recommended.
150
+ *
151
+ * @example
152
+ * {
153
+ * 'INSERT': sanitizeSQL`json_extract(NEW.data, '$.list_id') = ${sanitizeUUID(list.id)}`,
154
+ * 'INSERT': `TRUE`,
155
+ * 'UPDATE': sanitizeSQL`NEW.id = 'abcd' AND json_extract(NEW.data, '$.status') = 'active'`,
156
+ * 'DELETE': sanitizeSQL`json_extract(OLD.data, '$.list_id') = 'abcd'`
157
+ * }
158
+ */
159
+ when: Partial<Record<DiffTriggerOperation, string>>;
160
+
161
+ /**
162
+ * Hooks which allow execution during the trigger creation process.
163
+ */
164
+ hooks?: TriggerCreationHooks;
165
+ }
166
+
167
+ /**
168
+ * @experimental
169
+ * Options for {@link TriggerManager#createDiffTrigger}.
170
+ */
171
+ export interface CreateDiffTriggerOptions extends BaseCreateDiffTriggerOptions {
172
+ /**
173
+ * Destination table to send changes to.
174
+ * This table is created internally as a SQLite temporary table.
175
+ * This table will be dropped once the trigger is removed.
176
+ */
177
+ destination: string;
178
+ }
179
+
180
+ /**
181
+ * @experimental
182
+ * Callback to drop a trigger after it has been created.
183
+ */
184
+ export type TriggerRemoveCallback = () => Promise<void>;
185
+
186
+ /**
187
+ * @experimental
188
+ * Context for the `onChange` handler provided to {@link TriggerManager#trackTableDiff}.
189
+ */
190
+ export interface TriggerDiffHandlerContext extends LockContext {
191
+ /**
192
+ * The name of the temporary destination table created by the trigger.
193
+ */
194
+ destinationTable: string;
195
+
196
+ /**
197
+ * Allows querying the database with access to the table containing DIFF records.
198
+ * The diff table is accessible via the `DIFF` accessor.
199
+ *
200
+ * The `DIFF` table is of the form described in {@link TriggerManager#createDiffTrigger}
201
+ * ```sql
202
+ * CREATE TEMP DIFF (
203
+ * id TEXT,
204
+ * operation TEXT,
205
+ * timestamp TEXT
206
+ * value TEXT,
207
+ * previous_value TEXT
208
+ * );
209
+ * ```
210
+ *
211
+ * Note that the `value` and `previous_value` columns store the row state in JSON string format.
212
+ * To access the row state in an extracted form see {@link TriggerDiffHandlerContext#withExtractedDiff}.
213
+ *
214
+ * @example
215
+ * ```sql
216
+ * --- This fetches the current state of `todo` rows which have a diff operation present.
217
+ * --- The state of the row at the time of the operation is accessible in the DIFF records.
218
+ * SELECT
219
+ * todos.*
220
+ * FROM
221
+ * DIFF
222
+ * JOIN todos ON DIFF.id = todos.id
223
+ * WHERE json_extract(DIFF.value, '$.status') = 'active'
224
+ * ```
225
+ */
226
+ withDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>) => Promise<T[]>;
227
+
228
+ /**
229
+ * Allows querying the database with access to the table containing diff records.
230
+ * The diff table is accessible via the `DIFF` accessor.
231
+ *
232
+ * This is similar to {@link withDiff} but extracts the row columns from the tracked JSON value. The diff operation
233
+ * data is aliased as `__` columns to avoid column conflicts.
234
+ *
235
+ * For {@link DiffTriggerOperation#DELETE} operations the previous_value columns are extracted for convenience.
236
+ *
237
+ *
238
+ * ```sql
239
+ * CREATE TEMP TABLE DIFF (
240
+ * id TEXT,
241
+ * replicated_column_1 COLUMN_TYPE,
242
+ * replicated_column_2 COLUMN_TYPE,
243
+ * __operation TEXT,
244
+ * __timestamp TEXT,
245
+ * __previous_value TEXT
246
+ * );
247
+ * ```
248
+ *
249
+ * @example
250
+ * ```sql
251
+ * SELECT
252
+ * todos.*
253
+ * FROM
254
+ * DIFF
255
+ * JOIN todos ON DIFF.id = todos.id
256
+ * --- The todo column names are extracted from json and are available as DIFF.name
257
+ * WHERE DIFF.name = 'example'
258
+ * ```
259
+ */
260
+ withExtractedDiff: <T = any>(query: string, params?: ReadonlyArray<Readonly<any>>) => Promise<T[]>;
261
+ }
262
+
263
+ /**
264
+ * @experimental
265
+ * Options for tracking changes to a table with {@link TriggerManager#trackTableDiff}.
266
+ */
267
+ export interface TrackDiffOptions extends BaseCreateDiffTriggerOptions {
268
+ /**
269
+ * Handler for processing diff operations.
270
+ * Automatically invoked once diff items are present.
271
+ * Diff items are automatically cleared after the handler is invoked.
272
+ */
273
+ onChange: (context: TriggerDiffHandlerContext) => Promise<void>;
274
+
275
+ /**
276
+ * The minimum interval, in milliseconds, between {@link onChange} invocations.
277
+ * @default {@link DEFAULT_WATCH_THROTTLE_MS}
278
+ */
279
+ throttleMs?: number;
280
+ }
281
+
282
+ /**
283
+ * @experimental
284
+ */
285
+ export interface TriggerManager {
286
+ /**
287
+ * @experimental
288
+ * Creates a temporary trigger which tracks changes to a source table
289
+ * and writes changes to a destination table.
290
+ * The temporary destination table is created internally and will be dropped when the trigger is removed.
291
+ * The temporary destination table is created with the structure:
292
+ *
293
+ * ```sql
294
+ * CREATE TEMP TABLE ${destination} (
295
+ * id TEXT,
296
+ * operation TEXT,
297
+ * timestamp TEXT
298
+ * value TEXT,
299
+ * previous_value TEXT
300
+ * );
301
+ * ```
302
+ * The `value` column contains the JSON representation of the row's value at the change.
303
+ *
304
+ * For {@link DiffTriggerOperation#UPDATE} operations the `previous_value` column contains the previous value of the changed row
305
+ * in a JSON format.
306
+ *
307
+ * NB: The triggers created by this method might be invalidated by {@link AbstractPowerSyncDatabase#updateSchema} calls.
308
+ * These triggers should manually be dropped and recreated when updating the schema.
309
+ *
310
+ * @returns A callback to remove the trigger and drop the destination table.
311
+ *
312
+ * @example
313
+ * ```javascript
314
+ * const dispose = await database.triggers.createDiffTrigger({
315
+ * source: 'lists',
316
+ * destination: 'ps_temp_lists_diff',
317
+ * columns: ['name'],
318
+ * when: {
319
+ * [DiffTriggerOperation.INSERT]: 'TRUE',
320
+ * [DiffTriggerOperation.UPDATE]: 'TRUE',
321
+ * [DiffTriggerOperation.DELETE]: 'TRUE'
322
+ * }
323
+ * });
324
+ * ```
325
+ */
326
+ createDiffTrigger(options: CreateDiffTriggerOptions): Promise<TriggerRemoveCallback>;
327
+
328
+ /**
329
+ * @experimental
330
+ * Tracks changes for a table. Triggering a provided handler on changes.
331
+ * Uses {@link createDiffTrigger} internally to create a temporary destination table.
332
+ *
333
+ * @returns A callback to cleanup the trigger and stop tracking changes.
334
+ *
335
+ * NB: The triggers created by this method might be invalidated by {@link AbstractPowerSyncDatabase#updateSchema} calls.
336
+ * These triggers should manually be dropped and recreated when updating the schema.
337
+ *
338
+ * @example
339
+ * ```javascript
340
+ * const dispose = database.triggers.trackTableDiff({
341
+ * source: 'todos',
342
+ * columns: ['list_id'],
343
+ * when: {
344
+ * [DiffTriggerOperation.INSERT]: sanitizeSQL`json_extract(NEW.data, '$.list_id') = ${sanitizeUUID(someIdVariable)}`
345
+ * },
346
+ * onChange: async (context) => {
347
+ * // Fetches the todo records that were inserted during this diff
348
+ * const newTodos = await context.withDiff<Database['todos']>(`
349
+ * SELECT
350
+ * todos.*
351
+ * FROM
352
+ * DIFF
353
+ * JOIN todos ON DIFF.id = todos.id
354
+ * `);
355
+ *
356
+ * // Process newly created todos
357
+ * },
358
+ * hooks: {
359
+ * beforeCreate: async (lockContext) => {
360
+ * // This hook is executed inside the write lock before the trigger is created.
361
+ * // It can be used to synchronize the current state of the table with processor logic.
362
+ * // Any changes after this callback are guaranteed to trigger the `onChange` handler.
363
+ *
364
+ * // Read the current state of the todos table
365
+ * const currentTodos = await lockContext.getAll<Database['todos']>(
366
+ * `
367
+ * SELECT
368
+ * *
369
+ * FROM
370
+ * todos
371
+ * WHERE
372
+ * list_id = ?
373
+ * `,
374
+ * ['123']
375
+ * );
376
+ *
377
+ * // Process existing todos
378
+ * }
379
+ * }
380
+ * });
381
+ * ```
382
+ */
383
+ trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;
384
+ }
@@ -0,0 +1,314 @@
1
+ import { LockContext } from '../../db/DBAdapter.js';
2
+ import { Schema } from '../../db/schema/Schema.js';
3
+ import { type AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js';
4
+ import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js';
5
+ import {
6
+ CreateDiffTriggerOptions,
7
+ DiffTriggerOperation,
8
+ TrackDiffOptions,
9
+ TriggerManager,
10
+ TriggerRemoveCallback
11
+ } from './TriggerManager.js';
12
+
13
+ export type TriggerManagerImplOptions = {
14
+ db: AbstractPowerSyncDatabase;
15
+ schema: Schema;
16
+ };
17
+
18
+ export class TriggerManagerImpl implements TriggerManager {
19
+ protected schema: Schema;
20
+
21
+ constructor(protected options: TriggerManagerImplOptions) {
22
+ this.schema = options.schema;
23
+ options.db.registerListener({
24
+ schemaChanged: (schema) => {
25
+ this.schema = schema;
26
+ }
27
+ });
28
+ }
29
+
30
+ protected get db() {
31
+ return this.options.db;
32
+ }
33
+
34
+ protected async getUUID() {
35
+ const { id: uuid } = await this.db.get<{ id: string }>(/* sql */ `
36
+ SELECT
37
+ uuid () as id
38
+ `);
39
+
40
+ // Replace dashes with underscores for SQLite table/trigger name compatibility
41
+ return uuid.replace(/-/g, '_');
42
+ }
43
+
44
+ protected async removeTriggers(tx: LockContext, triggerIds: string[]) {
45
+ for (const triggerId of triggerIds) {
46
+ await tx.execute(/* sql */ `DROP TRIGGER IF EXISTS ${triggerId}; `);
47
+ }
48
+ }
49
+
50
+ async createDiffTrigger(options: CreateDiffTriggerOptions) {
51
+ await this.db.waitForReady();
52
+ const { source, destination, columns, when, hooks } = options;
53
+ const operations = Object.keys(when) as DiffTriggerOperation[];
54
+ if (operations.length == 0) {
55
+ throw new Error('At least one WHEN operation must be specified for the trigger.');
56
+ }
57
+
58
+ const whenClauses = Object.fromEntries(
59
+ Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`])
60
+ );
61
+
62
+ /**
63
+ * Allow specifying the View name as the source.
64
+ * We can lookup the internal table name from the schema.
65
+ */
66
+ const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
67
+ if (!sourceDefinition) {
68
+ throw new Error(`Source table or view "${source}" not found in the schema.`);
69
+ }
70
+
71
+ const replicatedColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
72
+
73
+ const internalSource = sourceDefinition.internalName;
74
+ const triggerIds: string[] = [];
75
+
76
+ const id = await this.getUUID();
77
+
78
+ /**
79
+ * We default to replicating all columns if no columns array is provided.
80
+ */
81
+ const jsonFragment = (source: 'NEW' | 'OLD' = 'NEW') => {
82
+ if (columns == null) {
83
+ // Track all columns
84
+ return `${source}.data`;
85
+ } else if (columns.length == 0) {
86
+ // Don't track any columns except for the id
87
+ return `'{}'`;
88
+ } else {
89
+ // Filter the data by the replicated columns
90
+ return `json_object(${replicatedColumns.map((col) => `'${col}', json_extract(${source}.data, '$.${col}')`).join(', ')})`;
91
+ }
92
+ };
93
+
94
+ const disposeWarningListener = this.db.registerListener({
95
+ schemaChanged: () => {
96
+ this.db.logger.warn(
97
+ `The PowerSync schema has changed while previously configured triggers are still operational. This might cause unexpected results.`
98
+ );
99
+ }
100
+ });
101
+
102
+ /**
103
+ * Declare the cleanup function early since if any of the init steps fail,
104
+ * we need to ensure we can cleanup the created resources.
105
+ * We unfortunately cannot rely on transaction rollback.
106
+ */
107
+ const cleanup = async () => {
108
+ disposeWarningListener();
109
+ return this.db.writeLock(async (tx) => {
110
+ await this.removeTriggers(tx, triggerIds);
111
+ await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
112
+ });
113
+ };
114
+
115
+ const setup = async (tx: LockContext) => {
116
+ // Allow user code to execute in this lock context before the trigger is created.
117
+ await hooks?.beforeCreate?.(tx);
118
+ await tx.execute(/* sql */ `
119
+ CREATE TEMP TABLE ${destination} (
120
+ id TEXT,
121
+ operation TEXT,
122
+ timestamp TEXT,
123
+ value TEXT,
124
+ previous_value TEXT
125
+ );
126
+ `);
127
+
128
+ if (operations.includes(DiffTriggerOperation.INSERT)) {
129
+ const insertTriggerId = `ps_temp_trigger_insert_${id}`;
130
+ triggerIds.push(insertTriggerId);
131
+
132
+ await tx.execute(/* sql */ `
133
+ CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[
134
+ DiffTriggerOperation.INSERT
135
+ ]} BEGIN
136
+ INSERT INTO
137
+ ${destination} (id, operation, timestamp, value)
138
+ VALUES
139
+ (
140
+ NEW.id,
141
+ 'INSERT',
142
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
143
+ ${jsonFragment('NEW')}
144
+ );
145
+
146
+ END;
147
+ `);
148
+ }
149
+
150
+ if (operations.includes(DiffTriggerOperation.UPDATE)) {
151
+ const updateTriggerId = `ps_temp_trigger_update_${id}`;
152
+ triggerIds.push(updateTriggerId);
153
+
154
+ await tx.execute(/* sql */ `
155
+ CREATE TEMP TRIGGER ${updateTriggerId} AFTER
156
+ UPDATE ON ${internalSource} ${whenClauses[DiffTriggerOperation.UPDATE]} BEGIN
157
+ INSERT INTO
158
+ ${destination} (id, operation, timestamp, value, previous_value)
159
+ VALUES
160
+ (
161
+ NEW.id,
162
+ 'UPDATE',
163
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
164
+ ${jsonFragment('NEW')},
165
+ ${jsonFragment('OLD')}
166
+ );
167
+
168
+ END;
169
+ `);
170
+ }
171
+
172
+ if (operations.includes(DiffTriggerOperation.DELETE)) {
173
+ const deleteTriggerId = `ps_temp_trigger_delete_${id}`;
174
+ triggerIds.push(deleteTriggerId);
175
+
176
+ // Create delete trigger for basic JSON
177
+ await tx.execute(/* sql */ `
178
+ CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[
179
+ DiffTriggerOperation.DELETE
180
+ ]} BEGIN
181
+ INSERT INTO
182
+ ${destination} (id, operation, timestamp, value)
183
+ VALUES
184
+ (
185
+ OLD.id,
186
+ 'DELETE',
187
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
188
+ ${jsonFragment('OLD')}
189
+ );
190
+
191
+ END;
192
+ `);
193
+ }
194
+ };
195
+
196
+ try {
197
+ await this.db.writeLock(setup);
198
+ return cleanup;
199
+ } catch (error) {
200
+ try {
201
+ await cleanup();
202
+ } catch (cleanupError) {
203
+ throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
204
+ }
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ async trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback> {
210
+ const { source, when, columns, hooks, throttleMs = DEFAULT_WATCH_THROTTLE_MS } = options;
211
+
212
+ await this.db.waitForReady();
213
+
214
+ /**
215
+ * Allow specifying the View name as the source.
216
+ * We can lookup the internal table name from the schema.
217
+ */
218
+ const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
219
+ if (!sourceDefinition) {
220
+ throw new Error(`Source table or view "${source}" not found in the schema.`);
221
+ }
222
+
223
+ // The columns to present in the onChange context methods.
224
+ // If no array is provided, we use all columns from the source table.
225
+ const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
226
+
227
+ const id = await this.getUUID();
228
+ const destination = `ps_temp_track_${source}_${id}`;
229
+
230
+ // register an onChange before the trigger is created
231
+ const abortController = new AbortController();
232
+ const abortOnChange = () => abortController.abort();
233
+ this.db.onChange(
234
+ {
235
+ // Note that the onChange events here have their execution scheduled.
236
+ // Callbacks are throttled and are sequential.
237
+ onChange: async () => {
238
+ if (abortController.signal.aborted) return;
239
+
240
+ // Run the handler in a write lock to keep the state of the
241
+ // destination table consistent.
242
+ await this.db.writeTransaction(async (tx) => {
243
+ const callbackResult = await options.onChange({
244
+ ...tx,
245
+ destinationTable: destination,
246
+ withDiff: async <T>(query, params) => {
247
+ // Wrap the query to expose the destination table
248
+ const wrappedQuery = /* sql */ `
249
+ WITH
250
+ DIFF AS (
251
+ SELECT
252
+ *
253
+ FROM
254
+ ${destination}
255
+ ORDER BY
256
+ timestamp ASC
257
+ ) ${query}
258
+ `;
259
+ return tx.getAll<T>(wrappedQuery, params);
260
+ },
261
+ withExtractedDiff: async <T>(query, params) => {
262
+ // Wrap the query to expose the destination table
263
+ const wrappedQuery = /* sql */ `
264
+ WITH
265
+ DIFF AS (
266
+ SELECT
267
+ id,
268
+ ${contextColumns.length > 0
269
+ ? `${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},`
270
+ : ''} operation as __operation,
271
+ timestamp as __timestamp,
272
+ previous_value as __previous_value
273
+ FROM
274
+ ${destination}
275
+ ORDER BY
276
+ __timestamp ASC
277
+ ) ${query}
278
+ `;
279
+ return tx.getAll<T>(wrappedQuery, params);
280
+ }
281
+ });
282
+
283
+ // Clear the destination table after processing
284
+ await tx.execute(/* sql */ `DELETE FROM ${destination};`);
285
+ return callbackResult;
286
+ });
287
+ }
288
+ },
289
+ { tables: [destination], signal: abortController.signal, throttleMs }
290
+ );
291
+
292
+ try {
293
+ const removeTrigger = await this.createDiffTrigger({
294
+ source,
295
+ destination,
296
+ columns: contextColumns,
297
+ when,
298
+ hooks
299
+ });
300
+
301
+ return async () => {
302
+ abortOnChange();
303
+ await removeTrigger();
304
+ };
305
+ } catch (error) {
306
+ try {
307
+ abortOnChange();
308
+ } catch (cleanupError) {
309
+ throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+ }