@powersync/common 1.36.0 → 1.38.0

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.
@@ -0,0 +1,265 @@
1
+ import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js';
2
+ import { DiffTriggerOperation } from './TriggerManager.js';
3
+ export class TriggerManagerImpl {
4
+ options;
5
+ schema;
6
+ constructor(options) {
7
+ this.options = options;
8
+ this.schema = options.schema;
9
+ options.db.registerListener({
10
+ schemaChanged: (schema) => {
11
+ this.schema = schema;
12
+ }
13
+ });
14
+ }
15
+ get db() {
16
+ return this.options.db;
17
+ }
18
+ async getUUID() {
19
+ const { id: uuid } = await this.db.get(/* sql */ `
20
+ SELECT
21
+ uuid () as id
22
+ `);
23
+ // Replace dashes with underscores for SQLite table/trigger name compatibility
24
+ return uuid.replace(/-/g, '_');
25
+ }
26
+ async removeTriggers(tx, triggerIds) {
27
+ for (const triggerId of triggerIds) {
28
+ await tx.execute(/* sql */ `DROP TRIGGER IF EXISTS ${triggerId}; `);
29
+ }
30
+ }
31
+ async createDiffTrigger(options) {
32
+ await this.db.waitForReady();
33
+ const { source, destination, columns, when, hooks } = options;
34
+ const operations = Object.keys(when);
35
+ if (operations.length == 0) {
36
+ throw new Error('At least one WHEN operation must be specified for the trigger.');
37
+ }
38
+ const whenClauses = Object.fromEntries(Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]));
39
+ /**
40
+ * Allow specifying the View name as the source.
41
+ * We can lookup the internal table name from the schema.
42
+ */
43
+ const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
44
+ if (!sourceDefinition) {
45
+ throw new Error(`Source table or view "${source}" not found in the schema.`);
46
+ }
47
+ const replicatedColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
48
+ const internalSource = sourceDefinition.internalName;
49
+ const triggerIds = [];
50
+ const id = await this.getUUID();
51
+ /**
52
+ * We default to replicating all columns if no columns array is provided.
53
+ */
54
+ const jsonFragment = (source = 'NEW') => {
55
+ if (columns == null) {
56
+ // Track all columns
57
+ return `${source}.data`;
58
+ }
59
+ else if (columns.length == 0) {
60
+ // Don't track any columns except for the id
61
+ return `'{}'`;
62
+ }
63
+ else {
64
+ // Filter the data by the replicated columns
65
+ return `json_object(${replicatedColumns.map((col) => `'${col}', json_extract(${source}.data, '$.${col}')`).join(', ')})`;
66
+ }
67
+ };
68
+ const disposeWarningListener = this.db.registerListener({
69
+ schemaChanged: () => {
70
+ this.db.logger.warn(`The PowerSync schema has changed while previously configured triggers are still operational. This might cause unexpected results.`);
71
+ }
72
+ });
73
+ /**
74
+ * Declare the cleanup function early since if any of the init steps fail,
75
+ * we need to ensure we can cleanup the created resources.
76
+ * We unfortunately cannot rely on transaction rollback.
77
+ */
78
+ const cleanup = async () => {
79
+ disposeWarningListener();
80
+ return this.db.writeLock(async (tx) => {
81
+ await this.removeTriggers(tx, triggerIds);
82
+ await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
83
+ });
84
+ };
85
+ const setup = async (tx) => {
86
+ // Allow user code to execute in this lock context before the trigger is created.
87
+ await hooks?.beforeCreate?.(tx);
88
+ await tx.execute(/* sql */ `
89
+ CREATE TEMP TABLE ${destination} (
90
+ id TEXT,
91
+ operation TEXT,
92
+ timestamp TEXT,
93
+ value TEXT,
94
+ previous_value TEXT
95
+ );
96
+ `);
97
+ if (operations.includes(DiffTriggerOperation.INSERT)) {
98
+ const insertTriggerId = `ps_temp_trigger_insert_${id}`;
99
+ triggerIds.push(insertTriggerId);
100
+ await tx.execute(/* sql */ `
101
+ CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[DiffTriggerOperation.INSERT]} BEGIN
102
+ INSERT INTO
103
+ ${destination} (id, operation, timestamp, value)
104
+ VALUES
105
+ (
106
+ NEW.id,
107
+ 'INSERT',
108
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
109
+ ${jsonFragment('NEW')}
110
+ );
111
+
112
+ END;
113
+ `);
114
+ }
115
+ if (operations.includes(DiffTriggerOperation.UPDATE)) {
116
+ const updateTriggerId = `ps_temp_trigger_update_${id}`;
117
+ triggerIds.push(updateTriggerId);
118
+ await tx.execute(/* sql */ `
119
+ CREATE TEMP TRIGGER ${updateTriggerId} AFTER
120
+ UPDATE ON ${internalSource} ${whenClauses[DiffTriggerOperation.UPDATE]} BEGIN
121
+ INSERT INTO
122
+ ${destination} (id, operation, timestamp, value, previous_value)
123
+ VALUES
124
+ (
125
+ NEW.id,
126
+ 'UPDATE',
127
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
128
+ ${jsonFragment('NEW')},
129
+ ${jsonFragment('OLD')}
130
+ );
131
+
132
+ END;
133
+ `);
134
+ }
135
+ if (operations.includes(DiffTriggerOperation.DELETE)) {
136
+ const deleteTriggerId = `ps_temp_trigger_delete_${id}`;
137
+ triggerIds.push(deleteTriggerId);
138
+ // Create delete trigger for basic JSON
139
+ await tx.execute(/* sql */ `
140
+ CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[DiffTriggerOperation.DELETE]} BEGIN
141
+ INSERT INTO
142
+ ${destination} (id, operation, timestamp, value)
143
+ VALUES
144
+ (
145
+ OLD.id,
146
+ 'DELETE',
147
+ strftime ('%Y-%m-%dT%H:%M:%fZ', 'now'),
148
+ ${jsonFragment('OLD')}
149
+ );
150
+
151
+ END;
152
+ `);
153
+ }
154
+ };
155
+ try {
156
+ await this.db.writeLock(setup);
157
+ return cleanup;
158
+ }
159
+ catch (error) {
160
+ try {
161
+ await cleanup();
162
+ }
163
+ catch (cleanupError) {
164
+ throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
165
+ }
166
+ throw error;
167
+ }
168
+ }
169
+ async trackTableDiff(options) {
170
+ const { source, when, columns, hooks, throttleMs = DEFAULT_WATCH_THROTTLE_MS } = options;
171
+ await this.db.waitForReady();
172
+ /**
173
+ * Allow specifying the View name as the source.
174
+ * We can lookup the internal table name from the schema.
175
+ */
176
+ const sourceDefinition = this.schema.tables.find((table) => table.viewName == source);
177
+ if (!sourceDefinition) {
178
+ throw new Error(`Source table or view "${source}" not found in the schema.`);
179
+ }
180
+ // The columns to present in the onChange context methods.
181
+ // If no array is provided, we use all columns from the source table.
182
+ const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
183
+ const id = await this.getUUID();
184
+ const destination = `ps_temp_track_${source}_${id}`;
185
+ // register an onChange before the trigger is created
186
+ const abortController = new AbortController();
187
+ const abortOnChange = () => abortController.abort();
188
+ this.db.onChange({
189
+ // Note that the onChange events here have their execution scheduled.
190
+ // Callbacks are throttled and are sequential.
191
+ onChange: async () => {
192
+ if (abortController.signal.aborted)
193
+ return;
194
+ // Run the handler in a write lock to keep the state of the
195
+ // destination table consistent.
196
+ await this.db.writeTransaction(async (tx) => {
197
+ const callbackResult = await options.onChange({
198
+ ...tx,
199
+ destinationTable: destination,
200
+ withDiff: async (query, params) => {
201
+ // Wrap the query to expose the destination table
202
+ const wrappedQuery = /* sql */ `
203
+ WITH
204
+ DIFF AS (
205
+ SELECT
206
+ *
207
+ FROM
208
+ ${destination}
209
+ ORDER BY
210
+ timestamp ASC
211
+ ) ${query}
212
+ `;
213
+ return tx.getAll(wrappedQuery, params);
214
+ },
215
+ withExtractedDiff: async (query, params) => {
216
+ // Wrap the query to expose the destination table
217
+ const wrappedQuery = /* sql */ `
218
+ WITH
219
+ DIFF AS (
220
+ SELECT
221
+ id,
222
+ ${contextColumns.length > 0
223
+ ? `${contextColumns.map((col) => `json_extract(value, '$.${col}') as ${col}`).join(', ')},`
224
+ : ''} operation as __operation,
225
+ timestamp as __timestamp,
226
+ previous_value as __previous_value
227
+ FROM
228
+ ${destination}
229
+ ORDER BY
230
+ __timestamp ASC
231
+ ) ${query}
232
+ `;
233
+ return tx.getAll(wrappedQuery, params);
234
+ }
235
+ });
236
+ // Clear the destination table after processing
237
+ await tx.execute(/* sql */ `DELETE FROM ${destination};`);
238
+ return callbackResult;
239
+ });
240
+ }
241
+ }, { tables: [destination], signal: abortController.signal, throttleMs });
242
+ try {
243
+ const removeTrigger = await this.createDiffTrigger({
244
+ source,
245
+ destination,
246
+ columns: contextColumns,
247
+ when,
248
+ hooks
249
+ });
250
+ return async () => {
251
+ abortOnChange();
252
+ await removeTrigger();
253
+ };
254
+ }
255
+ catch (error) {
256
+ try {
257
+ abortOnChange();
258
+ }
259
+ catch (cleanupError) {
260
+ throw new AggregateError([error, cleanupError], 'Error during operation and cleanup');
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Helper function for sanitizing UUID input strings.
3
+ * Typically used with {@link sanitizeSQL}.
4
+ */
5
+ export declare function sanitizeUUID(uuid: string): string;
6
+ /**
7
+ * SQL string template function for {@link TrackDiffOptions#when} and {@link CreateDiffTriggerOptions#when}.
8
+ *
9
+ * This function performs basic string interpolation for SQLite WHEN clauses.
10
+ *
11
+ * **String placeholders:**
12
+ * - All string values passed as placeholders are automatically wrapped in single quotes (`'`).
13
+ * - Do not manually wrap placeholders in single quotes in your template string; the function will handle quoting and escaping for you.
14
+ * - Any single quotes within the string value are escaped by doubling them (`''`), as required by SQL syntax.
15
+ *
16
+ * **Other types:**
17
+ * - `null` and `undefined` are converted to SQL `NULL`.
18
+ * - Objects are stringified using `JSON.stringify()` and wrapped in single quotes, with any single quotes inside the stringified value escaped.
19
+ * - Numbers and other primitive types are inserted directly.
20
+ *
21
+ * **Usage example:**
22
+ * ```typescript
23
+ * const myID = "O'Reilly";
24
+ * const clause = sanitizeSQL`New.id = ${myID}`;
25
+ * // Result: "New.id = 'O''Reilly'"
26
+ * ```
27
+ *
28
+ * Avoid manually quoting placeholders:
29
+ * ```typescript
30
+ * // Incorrect:
31
+ * sanitizeSQL`New.id = '${myID}'` // Produces double quotes: New.id = ''O''Reilly''
32
+ * ```
33
+ */
34
+ export declare function sanitizeSQL(strings: TemplateStringsArray, ...values: any[]): string;
@@ -0,0 +1,68 @@
1
+ function sanitizeString(input) {
2
+ return `'${input.replace(/'/g, "''")}'`;
3
+ }
4
+ /**
5
+ * Helper function for sanitizing UUID input strings.
6
+ * Typically used with {@link sanitizeSQL}.
7
+ */
8
+ export function sanitizeUUID(uuid) {
9
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ const isValid = uuidRegex.test(uuid);
11
+ if (!isValid) {
12
+ throw new Error(`${uuid} is not a valid UUID`);
13
+ }
14
+ return uuid;
15
+ }
16
+ /**
17
+ * SQL string template function for {@link TrackDiffOptions#when} and {@link CreateDiffTriggerOptions#when}.
18
+ *
19
+ * This function performs basic string interpolation for SQLite WHEN clauses.
20
+ *
21
+ * **String placeholders:**
22
+ * - All string values passed as placeholders are automatically wrapped in single quotes (`'`).
23
+ * - Do not manually wrap placeholders in single quotes in your template string; the function will handle quoting and escaping for you.
24
+ * - Any single quotes within the string value are escaped by doubling them (`''`), as required by SQL syntax.
25
+ *
26
+ * **Other types:**
27
+ * - `null` and `undefined` are converted to SQL `NULL`.
28
+ * - Objects are stringified using `JSON.stringify()` and wrapped in single quotes, with any single quotes inside the stringified value escaped.
29
+ * - Numbers and other primitive types are inserted directly.
30
+ *
31
+ * **Usage example:**
32
+ * ```typescript
33
+ * const myID = "O'Reilly";
34
+ * const clause = sanitizeSQL`New.id = ${myID}`;
35
+ * // Result: "New.id = 'O''Reilly'"
36
+ * ```
37
+ *
38
+ * Avoid manually quoting placeholders:
39
+ * ```typescript
40
+ * // Incorrect:
41
+ * sanitizeSQL`New.id = '${myID}'` // Produces double quotes: New.id = ''O''Reilly''
42
+ * ```
43
+ */
44
+ export function sanitizeSQL(strings, ...values) {
45
+ let result = '';
46
+ strings.forEach((str, i) => {
47
+ result += str;
48
+ if (i < values.length) {
49
+ // For SQL, escape single quotes in string values
50
+ const value = values[i];
51
+ if (typeof value == 'string') {
52
+ result += sanitizeString(value);
53
+ }
54
+ else if (value == null) {
55
+ result += 'NULL';
56
+ }
57
+ else if (typeof value == 'object') {
58
+ // Stringify the object and escape single quotes in the result
59
+ const stringified = JSON.stringify(value);
60
+ result += sanitizeString(stringified);
61
+ }
62
+ else {
63
+ result += value;
64
+ }
65
+ }
66
+ });
67
+ return result;
68
+ }
@@ -38,6 +38,7 @@ export class AbstractQueryProcessor extends MetaBaseObserver {
38
38
  * Updates the underlying query.
39
39
  */
40
40
  async updateSettings(settings) {
41
+ this.abortController.abort();
41
42
  await this.initialized;
42
43
  if (!this.state.isFetching && this.reportFetching) {
43
44
  await this.updateState({
@@ -45,7 +46,6 @@ export class AbstractQueryProcessor extends MetaBaseObserver {
45
46
  });
46
47
  }
47
48
  this.options.watchOptions = settings;
48
- this.abortController.abort();
49
49
  this.abortController = new AbortController();
50
50
  await this.runWithReporting(() => this.linkQuery({
51
51
  abortSignal: this.abortController.signal,
@@ -113,7 +113,7 @@ export class DifferentialQueryProcessor extends AbstractQueryProcessor {
113
113
  });
114
114
  db.onChangeWithCallback({
115
115
  onChange: async () => {
116
- if (this.closed) {
116
+ if (this.closed || abortSignal.aborted) {
117
117
  return;
118
118
  }
119
119
  // This fires for each change of the relevant tables
@@ -130,6 +130,9 @@ export class DifferentialQueryProcessor extends AbstractQueryProcessor {
130
130
  parameters: [...compiledQuery.parameters],
131
131
  db: this.options.db
132
132
  });
133
+ if (abortSignal.aborted) {
134
+ return;
135
+ }
133
136
  if (this.reportFetching) {
134
137
  partialStateUpdate.isFetching = false;
135
138
  }
@@ -26,7 +26,7 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor {
26
26
  });
27
27
  db.onChangeWithCallback({
28
28
  onChange: async () => {
29
- if (this.closed) {
29
+ if (this.closed || abortSignal.aborted) {
30
30
  return;
31
31
  }
32
32
  // This fires for each change of the relevant tables
@@ -43,6 +43,9 @@ export class OnChangeQueryProcessor extends AbstractQueryProcessor {
43
43
  parameters: [...compiledQuery.parameters],
44
44
  db: this.options.db
45
45
  });
46
+ if (abortSignal.aborted) {
47
+ return;
48
+ }
46
49
  if (this.reportFetching) {
47
50
  partialStateUpdate.isFetching = false;
48
51
  }
@@ -1,3 +1,4 @@
1
+ import { SyncClientImplementation } from '../../client/sync/stream/AbstractStreamingSyncImplementation.js';
1
2
  import { InternalProgressInformation, SyncProgress } from './SyncProgress.js';
2
3
  export type SyncDataFlowStatus = Partial<{
3
4
  downloading: boolean;
@@ -32,10 +33,18 @@ export type SyncStatusOptions = {
32
33
  lastSyncedAt?: Date;
33
34
  hasSynced?: boolean;
34
35
  priorityStatusEntries?: SyncPriorityStatus[];
36
+ clientImplementation?: SyncClientImplementation;
35
37
  };
36
38
  export declare class SyncStatus {
37
39
  protected options: SyncStatusOptions;
38
40
  constructor(options: SyncStatusOptions);
41
+ /**
42
+ * Returns the used sync client implementation (either the one implemented in JavaScript or the newer Rust-based
43
+ * implementation).
44
+ *
45
+ * This information is only available after a connection has been requested.
46
+ */
47
+ get clientImplementation(): SyncClientImplementation | undefined;
39
48
  /**
40
49
  * Indicates if the client is currently connected to the PowerSync service.
41
50
  *
@@ -4,6 +4,15 @@ export class SyncStatus {
4
4
  constructor(options) {
5
5
  this.options = options;
6
6
  }
7
+ /**
8
+ * Returns the used sync client implementation (either the one implemented in JavaScript or the newer Rust-based
9
+ * implementation).
10
+ *
11
+ * This information is only available after a connection has been requested.
12
+ */
13
+ get clientImplementation() {
14
+ return this.options.clientImplementation;
15
+ }
7
16
  /**
8
17
  * Indicates if the client is currently connected to the PowerSync service.
9
18
  *
package/lib/index.d.ts CHANGED
@@ -30,6 +30,8 @@ export * from './db/schema/Schema.js';
30
30
  export * from './db/schema/Table.js';
31
31
  export * from './db/schema/TableV2.js';
32
32
  export * from './client/Query.js';
33
+ export * from './client/triggers/sanitizeSQL.js';
34
+ export * from './client/triggers/TriggerManager.js';
33
35
  export * from './client/watched/GetAllQuery.js';
34
36
  export * from './client/watched/processors/AbstractQueryProcessor.js';
35
37
  export * from './client/watched/processors/comparators.js';
package/lib/index.js CHANGED
@@ -30,6 +30,8 @@ export * from './db/schema/Schema.js';
30
30
  export * from './db/schema/Table.js';
31
31
  export * from './db/schema/TableV2.js';
32
32
  export * from './client/Query.js';
33
+ export * from './client/triggers/sanitizeSQL.js';
34
+ export * from './client/triggers/TriggerManager.js';
33
35
  export * from './client/watched/GetAllQuery.js';
34
36
  export * from './client/watched/processors/AbstractQueryProcessor.js';
35
37
  export * from './client/watched/processors/comparators.js';
@@ -1,3 +1,12 @@
1
+ /**
2
+ * A ponyfill for `Symbol.asyncIterator` that is compatible with the
3
+ * [recommended polyfill](https://github.com/Azure/azure-sdk-for-js/blob/%40azure/core-asynciterator-polyfill_1.0.2/sdk/core/core-asynciterator-polyfill/src/index.ts#L4-L6)
4
+ * we recommend for React Native.
5
+ *
6
+ * As long as we use this symbol (instead of `for await` and `async *`) in this package, we can be compatible with async
7
+ * iterators without requiring them.
8
+ */
9
+ export declare const symbolAsyncIterator: typeof Symbol.asyncIterator;
1
10
  /**
2
11
  * Throttle a function to be called at most once every "wait" milliseconds,
3
12
  * on the trailing edge.
@@ -1,3 +1,12 @@
1
+ /**
2
+ * A ponyfill for `Symbol.asyncIterator` that is compatible with the
3
+ * [recommended polyfill](https://github.com/Azure/azure-sdk-for-js/blob/%40azure/core-asynciterator-polyfill_1.0.2/sdk/core/core-asynciterator-polyfill/src/index.ts#L4-L6)
4
+ * we recommend for React Native.
5
+ *
6
+ * As long as we use this symbol (instead of `for await` and `async *`) in this package, we can be compatible with async
7
+ * iterators without requiring them.
8
+ */
9
+ export const symbolAsyncIterator = Symbol.asyncIterator ?? Symbol.for('Symbol.asyncIterator');
1
10
  /**
2
11
  * Throttle a function to be called at most once every "wait" milliseconds,
3
12
  * on the trailing edge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powersync/common",
3
- "version": "1.36.0",
3
+ "version": "1.38.0",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org/",
6
6
  "access": "public"