@powersync/common 1.45.0 → 1.47.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.
Files changed (79) hide show
  1. package/README.md +5 -1
  2. package/dist/bundle.cjs +4165 -4914
  3. package/dist/bundle.cjs.map +1 -1
  4. package/dist/bundle.mjs +4155 -4915
  5. package/dist/bundle.mjs.map +1 -1
  6. package/dist/bundle.node.cjs +1502 -422
  7. package/dist/bundle.node.cjs.map +1 -1
  8. package/dist/bundle.node.mjs +1492 -423
  9. package/dist/bundle.node.mjs.map +1 -1
  10. package/dist/index.d.cts +622 -42
  11. package/lib/attachments/AttachmentContext.d.ts +86 -0
  12. package/lib/attachments/AttachmentContext.js +229 -0
  13. package/lib/attachments/AttachmentContext.js.map +1 -0
  14. package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
  15. package/lib/attachments/AttachmentErrorHandler.js +2 -0
  16. package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
  17. package/lib/attachments/AttachmentQueue.d.ts +149 -0
  18. package/lib/attachments/AttachmentQueue.js +362 -0
  19. package/lib/attachments/AttachmentQueue.js.map +1 -0
  20. package/lib/attachments/AttachmentService.d.ts +29 -0
  21. package/lib/attachments/AttachmentService.js +56 -0
  22. package/lib/attachments/AttachmentService.js.map +1 -0
  23. package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
  24. package/lib/attachments/LocalStorageAdapter.js +6 -0
  25. package/lib/attachments/LocalStorageAdapter.js.map +1 -0
  26. package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
  27. package/lib/attachments/RemoteStorageAdapter.js +2 -0
  28. package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
  29. package/lib/attachments/Schema.d.ts +50 -0
  30. package/lib/attachments/Schema.js +62 -0
  31. package/lib/attachments/Schema.js.map +1 -0
  32. package/lib/attachments/SyncingService.d.ts +62 -0
  33. package/lib/attachments/SyncingService.js +168 -0
  34. package/lib/attachments/SyncingService.js.map +1 -0
  35. package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
  36. package/lib/attachments/WatchedAttachmentItem.js +2 -0
  37. package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
  38. package/lib/client/AbstractPowerSyncDatabase.d.ts +9 -2
  39. package/lib/client/AbstractPowerSyncDatabase.js +18 -5
  40. package/lib/client/AbstractPowerSyncDatabase.js.map +1 -1
  41. package/lib/client/sync/stream/AbstractRemote.js +41 -32
  42. package/lib/client/sync/stream/AbstractRemote.js.map +1 -1
  43. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +7 -12
  44. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +10 -12
  45. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -1
  46. package/lib/client/triggers/MemoryTriggerClaimManager.d.ts +6 -0
  47. package/lib/client/triggers/MemoryTriggerClaimManager.js +21 -0
  48. package/lib/client/triggers/MemoryTriggerClaimManager.js.map +1 -0
  49. package/lib/client/triggers/TriggerManager.d.ts +37 -0
  50. package/lib/client/triggers/TriggerManagerImpl.d.ts +24 -3
  51. package/lib/client/triggers/TriggerManagerImpl.js +133 -11
  52. package/lib/client/triggers/TriggerManagerImpl.js.map +1 -1
  53. package/lib/index.d.ts +13 -0
  54. package/lib/index.js +13 -0
  55. package/lib/index.js.map +1 -1
  56. package/lib/utils/DataStream.js +11 -2
  57. package/lib/utils/DataStream.js.map +1 -1
  58. package/lib/utils/mutex.d.ts +1 -1
  59. package/lib/utils/mutex.js.map +1 -1
  60. package/package.json +4 -3
  61. package/src/attachments/AttachmentContext.ts +279 -0
  62. package/src/attachments/AttachmentErrorHandler.ts +34 -0
  63. package/src/attachments/AttachmentQueue.ts +472 -0
  64. package/src/attachments/AttachmentService.ts +62 -0
  65. package/src/attachments/LocalStorageAdapter.ts +72 -0
  66. package/src/attachments/README.md +718 -0
  67. package/src/attachments/RemoteStorageAdapter.ts +30 -0
  68. package/src/attachments/Schema.ts +87 -0
  69. package/src/attachments/SyncingService.ts +193 -0
  70. package/src/attachments/WatchedAttachmentItem.ts +19 -0
  71. package/src/client/AbstractPowerSyncDatabase.ts +21 -6
  72. package/src/client/sync/stream/AbstractRemote.ts +47 -35
  73. package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +11 -14
  74. package/src/client/triggers/MemoryTriggerClaimManager.ts +25 -0
  75. package/src/client/triggers/TriggerManager.ts +50 -6
  76. package/src/client/triggers/TriggerManagerImpl.ts +177 -13
  77. package/src/index.ts +14 -0
  78. package/src/utils/DataStream.ts +13 -2
  79. package/src/utils/mutex.ts +1 -1
@@ -1,24 +1,62 @@
1
1
  import { LockContext } from '../../db/DBAdapter.js';
2
2
  import { Schema } from '../../db/schema/Schema.js';
3
- import { type AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js';
3
+ import type { AbstractPowerSyncDatabase } from '../AbstractPowerSyncDatabase.js';
4
4
  import { DEFAULT_WATCH_THROTTLE_MS } from '../watched/WatchedQuery.js';
5
5
  import {
6
6
  CreateDiffTriggerOptions,
7
7
  DiffTriggerOperation,
8
8
  TrackDiffOptions,
9
9
  TriggerManager,
10
+ TriggerManagerConfig,
10
11
  TriggerRemoveCallback,
11
12
  WithDiffOptions
12
13
  } from './TriggerManager.js';
13
14
 
14
- export type TriggerManagerImplOptions = {
15
+ export type TriggerManagerImplOptions = TriggerManagerConfig & {
15
16
  db: AbstractPowerSyncDatabase;
16
17
  schema: Schema;
17
18
  };
18
19
 
20
+ export type TriggerManagerImplConfiguration = {
21
+ useStorageByDefault: boolean;
22
+ };
23
+
24
+ export const DEFAULT_TRIGGER_MANAGER_CONFIGURATION: TriggerManagerImplConfiguration = {
25
+ useStorageByDefault: false
26
+ };
27
+
28
+ /**
29
+ * A record of persisted table/trigger information.
30
+ * This is used for fail-safe cleanup.
31
+ */
32
+ type TrackedTableRecord = {
33
+ /**
34
+ * The id of the trigger. This is used in the SQLite trigger name
35
+ */
36
+ id: string;
37
+ /**
38
+ * The destination table name for the trigger
39
+ */
40
+ table: string;
41
+ /**
42
+ * Array of actual trigger names found for this table/id combo
43
+ */
44
+ triggerNames: string[];
45
+ };
46
+
47
+ const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes
48
+
49
+ /**
50
+ * @internal
51
+ * @experimental
52
+ */
19
53
  export class TriggerManagerImpl implements TriggerManager {
20
54
  protected schema: Schema;
21
55
 
56
+ protected defaultConfig: TriggerManagerImplConfiguration;
57
+ protected cleanupTimeout: ReturnType<typeof setTimeout> | null;
58
+ protected isDisposed: boolean;
59
+
22
60
  constructor(protected options: TriggerManagerImplOptions) {
23
61
  this.schema = options.schema;
24
62
  options.db.registerListener({
@@ -26,6 +64,32 @@ export class TriggerManagerImpl implements TriggerManager {
26
64
  this.schema = schema;
27
65
  }
28
66
  });
67
+ this.isDisposed = false;
68
+
69
+ /**
70
+ * Configure a cleanup to run on an interval.
71
+ * The interval is configured using setTimeout to take the async
72
+ * execution time of the callback into account.
73
+ */
74
+ this.defaultConfig = DEFAULT_TRIGGER_MANAGER_CONFIGURATION;
75
+ const cleanupCallback = async () => {
76
+ this.cleanupTimeout = null;
77
+ if (this.isDisposed) {
78
+ return;
79
+ }
80
+ try {
81
+ await this.cleanupResources();
82
+ } catch (ex) {
83
+ this.db.logger.error(`Caught error while attempting to cleanup triggers`, ex);
84
+ } finally {
85
+ // if not closed, set another timeout
86
+ if (this.isDisposed) {
87
+ return;
88
+ }
89
+ this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
90
+ }
91
+ };
92
+ this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
29
93
  }
30
94
 
31
95
  protected get db() {
@@ -48,14 +112,111 @@ export class TriggerManagerImpl implements TriggerManager {
48
112
  }
49
113
  }
50
114
 
115
+ dispose() {
116
+ this.isDisposed = true;
117
+ if (this.cleanupTimeout) {
118
+ clearTimeout(this.cleanupTimeout);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Updates default config settings for platform specific use-cases.
124
+ */
125
+ updateDefaults(config: TriggerManagerImplConfiguration) {
126
+ this.defaultConfig = {
127
+ ...this.defaultConfig,
128
+ ...config
129
+ };
130
+ }
131
+
132
+ protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string) {
133
+ return `__ps_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`;
134
+ }
135
+
136
+ /**
137
+ * Cleanup any SQLite triggers or tables that are no longer in use.
138
+ */
139
+ async cleanupResources() {
140
+ // we use the database here since cleanupResources is called during the PowerSyncDatabase initialization
141
+ await this.db.database.writeLock(async (ctx) => {
142
+ /**
143
+ * Note: We only cleanup persisted triggers. These are tracked in the sqlite_master table.
144
+ * temporary triggers will not be affected by this.
145
+ * Query all triggers that match our naming pattern
146
+ */
147
+ const triggers = await ctx.getAll<{ name: string }>(/* sql */ `
148
+ SELECT
149
+ name
150
+ FROM
151
+ sqlite_master
152
+ WHERE
153
+ type = 'trigger'
154
+ AND name LIKE '__ps_temp_trigger_%'
155
+ `);
156
+
157
+ /** Use regex to extract table names and IDs from trigger names
158
+ * Trigger naming convention: __ps_temp_trigger_<operation>__<destination_table>__<id>
159
+ */
160
+ const triggerPattern = /^__ps_temp_trigger_(?:insert|update|delete)__(.+)__([a-f0-9_]{36})$/i;
161
+ const trackedItems = new Map<string, TrackedTableRecord>();
162
+
163
+ for (const trigger of triggers) {
164
+ const match = trigger.name.match(triggerPattern);
165
+ if (match) {
166
+ const [, table, id] = match;
167
+ // Collect all trigger names for each id combo
168
+ const existing = trackedItems.get(id);
169
+ if (existing) {
170
+ existing.triggerNames.push(trigger.name);
171
+ } else {
172
+ trackedItems.set(id, { table, id, triggerNames: [trigger.name] });
173
+ }
174
+ }
175
+ }
176
+
177
+ for (const trackedItem of trackedItems.values()) {
178
+ // check if there is anything holding on to this item
179
+ const hasClaim = await this.options.claimManager.checkClaim(trackedItem.id);
180
+ if (hasClaim) {
181
+ // This does not require cleanup
182
+ continue;
183
+ }
184
+
185
+ this.db.logger.debug(`Clearing resources for trigger ${trackedItem.id} with table ${trackedItem.table}`);
186
+
187
+ // We need to delete the triggers and table
188
+ for (const triggerName of trackedItem.triggerNames) {
189
+ await ctx.execute(`DROP TRIGGER IF EXISTS ${triggerName}`);
190
+ }
191
+ await ctx.execute(`DROP TABLE IF EXISTS ${trackedItem.table}`);
192
+ }
193
+ });
194
+ }
195
+
51
196
  async createDiffTrigger(options: CreateDiffTriggerOptions) {
52
197
  await this.db.waitForReady();
53
- const { source, destination, columns, when, hooks } = options;
198
+ const {
199
+ source,
200
+ destination,
201
+ columns,
202
+ when,
203
+ hooks,
204
+ // Fall back to the provided default if not given on this level
205
+ useStorage = this.defaultConfig.useStorageByDefault
206
+ } = options;
54
207
  const operations = Object.keys(when) as DiffTriggerOperation[];
55
208
  if (operations.length == 0) {
56
209
  throw new Error('At least one WHEN operation must be specified for the trigger.');
57
210
  }
58
211
 
212
+ /**
213
+ * The clause to use when executing
214
+ * CREATE ${tableTriggerTypeClause} TABLE
215
+ * OR
216
+ * CREATE ${tableTriggerTypeClause} TRIGGER
217
+ */
218
+ const tableTriggerTypeClause = !useStorage ? 'TEMP' : '';
219
+
59
220
  const whenClauses = Object.fromEntries(
60
221
  Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`])
61
222
  );
@@ -76,6 +237,8 @@ export class TriggerManagerImpl implements TriggerManager {
76
237
 
77
238
  const id = await this.getUUID();
78
239
 
240
+ const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null;
241
+
79
242
  /**
80
243
  * We default to replicating all columns if no columns array is provided.
81
244
  */
@@ -110,6 +273,7 @@ export class TriggerManagerImpl implements TriggerManager {
110
273
  return this.db.writeLock(async (tx) => {
111
274
  await this.removeTriggers(tx, triggerIds);
112
275
  await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
276
+ await releaseStorageClaim?.();
113
277
  });
114
278
  };
115
279
 
@@ -117,22 +281,22 @@ export class TriggerManagerImpl implements TriggerManager {
117
281
  // Allow user code to execute in this lock context before the trigger is created.
118
282
  await hooks?.beforeCreate?.(tx);
119
283
  await tx.execute(/* sql */ `
120
- CREATE TEMP TABLE ${destination} (
284
+ CREATE ${tableTriggerTypeClause} TABLE ${destination} (
121
285
  operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
122
286
  id TEXT,
123
287
  operation TEXT,
124
288
  timestamp TEXT,
125
289
  value TEXT,
126
290
  previous_value TEXT
127
- );
291
+ )
128
292
  `);
129
293
 
130
294
  if (operations.includes(DiffTriggerOperation.INSERT)) {
131
- const insertTriggerId = `ps_temp_trigger_insert_${id}`;
295
+ const insertTriggerId = this.generateTriggerName(DiffTriggerOperation.INSERT, destination, id);
132
296
  triggerIds.push(insertTriggerId);
133
297
 
134
298
  await tx.execute(/* sql */ `
135
- CREATE TEMP TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[
299
+ CREATE ${tableTriggerTypeClause} TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[
136
300
  DiffTriggerOperation.INSERT
137
301
  ]} BEGIN
138
302
  INSERT INTO
@@ -145,16 +309,16 @@ export class TriggerManagerImpl implements TriggerManager {
145
309
  ${jsonFragment('NEW')}
146
310
  );
147
311
 
148
- END;
312
+ END
149
313
  `);
150
314
  }
151
315
 
152
316
  if (operations.includes(DiffTriggerOperation.UPDATE)) {
153
- const updateTriggerId = `ps_temp_trigger_update_${id}`;
317
+ const updateTriggerId = this.generateTriggerName(DiffTriggerOperation.UPDATE, destination, id);
154
318
  triggerIds.push(updateTriggerId);
155
319
 
156
320
  await tx.execute(/* sql */ `
157
- CREATE TEMP TRIGGER ${updateTriggerId} AFTER
321
+ CREATE ${tableTriggerTypeClause} TRIGGER ${updateTriggerId} AFTER
158
322
  UPDATE ON ${internalSource} ${whenClauses[DiffTriggerOperation.UPDATE]} BEGIN
159
323
  INSERT INTO
160
324
  ${destination} (id, operation, timestamp, value, previous_value)
@@ -172,12 +336,12 @@ export class TriggerManagerImpl implements TriggerManager {
172
336
  }
173
337
 
174
338
  if (operations.includes(DiffTriggerOperation.DELETE)) {
175
- const deleteTriggerId = `ps_temp_trigger_delete_${id}`;
339
+ const deleteTriggerId = this.generateTriggerName(DiffTriggerOperation.DELETE, destination, id);
176
340
  triggerIds.push(deleteTriggerId);
177
341
 
178
342
  // Create delete trigger for basic JSON
179
343
  await tx.execute(/* sql */ `
180
- CREATE TEMP TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[
344
+ CREATE ${tableTriggerTypeClause} TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[
181
345
  DiffTriggerOperation.DELETE
182
346
  ]} BEGIN
183
347
  INSERT INTO
@@ -227,7 +391,7 @@ export class TriggerManagerImpl implements TriggerManager {
227
391
  const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
228
392
 
229
393
  const id = await this.getUUID();
230
- const destination = `ps_temp_track_${source}_${id}`;
394
+ const destination = `__ps_temp_track_${source}_${id}`;
231
395
 
232
396
  // register an onChange before the trigger is created
233
397
  const abortController = new AbortController();
package/src/index.ts CHANGED
@@ -1,3 +1,13 @@
1
+ export * from './attachments/AttachmentContext.js';
2
+ export * from './attachments/AttachmentErrorHandler.js';
3
+ export * from './attachments/AttachmentQueue.js';
4
+ export * from './attachments/AttachmentService.js';
5
+ export * from './attachments/LocalStorageAdapter.js';
6
+ export * from './attachments/RemoteStorageAdapter.js';
7
+ export * from './attachments/Schema.js';
8
+ export * from './attachments/SyncingService.js';
9
+ export * from './attachments/WatchedAttachmentItem.js';
10
+
1
11
  export * from './client/AbstractPowerSyncDatabase.js';
2
12
  export * from './client/AbstractPowerSyncOpenFactory.js';
3
13
  export { compilableQueryWatch, CompilableQueryWatchHandler } from './client/compilableQueryWatch.js';
@@ -29,13 +39,16 @@ export * from './db/DBAdapter.js';
29
39
  export * from './db/schema/Column.js';
30
40
  export * from './db/schema/Index.js';
31
41
  export * from './db/schema/IndexedColumn.js';
42
+ export * from './db/schema/RawTable.js';
32
43
  export * from './db/schema/Schema.js';
33
44
  export * from './db/schema/Table.js';
34
45
  export * from './db/schema/TableV2.js';
35
46
 
36
47
  export * from './client/Query.js';
48
+ export { MEMORY_TRIGGER_CLAIM_MANAGER } from './client/triggers/MemoryTriggerClaimManager.js';
37
49
  export * from './client/triggers/sanitizeSQL.js';
38
50
  export * from './client/triggers/TriggerManager.js';
51
+ export { TriggerManagerImpl } from './client/triggers/TriggerManagerImpl.js';
39
52
  export * from './client/watched/GetAllQuery.js';
40
53
  export * from './client/watched/processors/AbstractQueryProcessor.js';
41
54
  export * from './client/watched/processors/comparators.js';
@@ -48,6 +61,7 @@ export * from './utils/BaseObserver.js';
48
61
  export * from './utils/ControlledExecutor.js';
49
62
  export * from './utils/DataStream.js';
50
63
  export * from './utils/Logger.js';
64
+ export * from './utils/mutex.js';
51
65
  export * from './utils/parseQuery.js';
52
66
 
53
67
  export * from './types/types.js';
@@ -110,6 +110,18 @@ export class DataStream<ParsedData, SourceData = any> extends BaseObserver<DataS
110
110
  return null;
111
111
  }
112
112
 
113
+ // Wait for any pending processing to complete first.
114
+ // This ensures we register our listener before calling processQueue(),
115
+ // avoiding a race where processQueue() sees no reader and returns early.
116
+ if (this.processingPromise) {
117
+ await this.processingPromise;
118
+ }
119
+
120
+ // Re-check after await - stream may have closed while we were waiting
121
+ if (this.closed) {
122
+ return null;
123
+ }
124
+
113
125
  return new Promise((resolve, reject) => {
114
126
  const l = this.registerListener({
115
127
  data: async (data) => {
@@ -151,7 +163,7 @@ export class DataStream<ParsedData, SourceData = any> extends BaseObserver<DataS
151
163
 
152
164
  const promise = (this.processingPromise = this._processQueue());
153
165
  promise.finally(() => {
154
- return (this.processingPromise = null);
166
+ this.processingPromise = null;
155
167
  });
156
168
  return promise;
157
169
  }
@@ -190,7 +202,6 @@ export class DataStream<ParsedData, SourceData = any> extends BaseObserver<DataS
190
202
  }
191
203
 
192
204
  if (this.dataQueue.length > 0) {
193
- // Next tick
194
205
  setTimeout(() => this.processQueue());
195
206
  }
196
207
  }
@@ -6,7 +6,7 @@ import { Mutex } from 'async-mutex';
6
6
  export async function mutexRunExclusive<T>(
7
7
  mutex: Mutex,
8
8
  callback: () => Promise<T>,
9
- options?: { timeoutMs: number }
9
+ options?: { timeoutMs?: number }
10
10
  ): Promise<T> {
11
11
  return new Promise((resolve, reject) => {
12
12
  const timeout = options?.timeoutMs;