@powersync/common 1.44.0 → 1.46.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 (49) hide show
  1. package/dist/bundle.cjs +442 -2057
  2. package/dist/bundle.cjs.map +1 -1
  3. package/dist/bundle.mjs +439 -2058
  4. package/dist/bundle.mjs.map +1 -1
  5. package/dist/bundle.node.cjs +341 -127
  6. package/dist/bundle.node.cjs.map +1 -1
  7. package/dist/bundle.node.mjs +338 -128
  8. package/dist/bundle.node.mjs.map +1 -1
  9. package/dist/index.d.cts +263 -164
  10. package/lib/client/AbstractPowerSyncDatabase.d.ts +9 -2
  11. package/lib/client/AbstractPowerSyncDatabase.js +18 -5
  12. package/lib/client/AbstractPowerSyncDatabase.js.map +1 -1
  13. package/lib/client/ConnectionManager.d.ts +1 -1
  14. package/lib/client/ConnectionManager.js.map +1 -1
  15. package/lib/client/sync/stream/AbstractRemote.js +41 -32
  16. package/lib/client/sync/stream/AbstractRemote.js.map +1 -1
  17. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +7 -12
  18. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +10 -12
  19. package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -1
  20. package/lib/client/triggers/MemoryTriggerClaimManager.d.ts +6 -0
  21. package/lib/client/triggers/MemoryTriggerClaimManager.js +21 -0
  22. package/lib/client/triggers/MemoryTriggerClaimManager.js.map +1 -0
  23. package/lib/client/triggers/TriggerManager.d.ts +37 -0
  24. package/lib/client/triggers/TriggerManagerImpl.d.ts +24 -3
  25. package/lib/client/triggers/TriggerManagerImpl.js +133 -11
  26. package/lib/client/triggers/TriggerManagerImpl.js.map +1 -1
  27. package/lib/db/ConnectionClosedError.d.ts +10 -0
  28. package/lib/db/ConnectionClosedError.js +21 -0
  29. package/lib/db/ConnectionClosedError.js.map +1 -0
  30. package/lib/db/crud/SyncStatus.d.ts +11 -2
  31. package/lib/db/crud/SyncStatus.js +19 -1
  32. package/lib/db/crud/SyncStatus.js.map +1 -1
  33. package/lib/index.d.ts +4 -0
  34. package/lib/index.js +4 -0
  35. package/lib/index.js.map +1 -1
  36. package/lib/utils/DataStream.js +11 -2
  37. package/lib/utils/DataStream.js.map +1 -1
  38. package/package.json +4 -3
  39. package/src/client/AbstractPowerSyncDatabase.ts +21 -6
  40. package/src/client/ConnectionManager.ts +1 -1
  41. package/src/client/sync/stream/AbstractRemote.ts +47 -35
  42. package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +11 -15
  43. package/src/client/triggers/MemoryTriggerClaimManager.ts +25 -0
  44. package/src/client/triggers/TriggerManager.ts +50 -6
  45. package/src/client/triggers/TriggerManagerImpl.ts +177 -13
  46. package/src/db/ConnectionClosedError.ts +23 -0
  47. package/src/db/crud/SyncStatus.ts +22 -3
  48. package/src/index.ts +4 -0
  49. package/src/utils/DataStream.ts +13 -2
@@ -40,7 +40,8 @@ import {
40
40
  } from './sync/stream/AbstractStreamingSyncImplementation.js';
41
41
  import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.js';
42
42
  import { SyncStream } from './sync/sync-streams.js';
43
- import { TriggerManager } from './triggers/TriggerManager.js';
43
+ import { MEMORY_TRIGGER_CLAIM_MANAGER } from './triggers/MemoryTriggerClaimManager.js';
44
+ import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js';
44
45
  import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js';
45
46
  import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js';
46
47
  import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js';
@@ -222,6 +223,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
222
223
  * Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
223
224
  */
224
225
  readonly triggers: TriggerManager;
226
+ protected triggersImpl: TriggerManagerImpl;
225
227
 
226
228
  logger: ILogger;
227
229
 
@@ -296,9 +298,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
296
298
 
297
299
  this._isReadyPromise = this.initialize();
298
300
 
299
- this.triggers = new TriggerManagerImpl({
301
+ this.triggers = this.triggersImpl = new TriggerManagerImpl({
300
302
  db: this,
301
- schema: this.schema
303
+ schema: this.schema,
304
+ ...this.generateTriggerManagerConfig()
302
305
  });
303
306
  }
304
307
 
@@ -334,6 +337,16 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
334
337
  */
335
338
  protected abstract openDBAdapter(options: PowerSyncDatabaseOptionsWithSettings): DBAdapter;
336
339
 
340
+ /**
341
+ * Generates a base configuration for {@link TriggerManagerImpl}.
342
+ * Implementations should override this if necessary.
343
+ */
344
+ protected generateTriggerManagerConfig(): TriggerManagerConfig {
345
+ return {
346
+ claimManager: MEMORY_TRIGGER_CLAIM_MANAGER
347
+ };
348
+ }
349
+
337
350
  protected abstract generateSyncStreamImplementation(
338
351
  connector: PowerSyncBackendConnector,
339
352
  options: CreateSyncImplementationOptions & RequiredAdditionalConnectionOptions
@@ -416,15 +429,16 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
416
429
  protected async initialize() {
417
430
  await this._initialize();
418
431
  await this.bucketStorageAdapter.init();
419
- await this._loadVersion();
432
+ await this.loadVersion();
420
433
  await this.updateSchema(this.options.schema);
421
434
  await this.resolveOfflineSyncStatus();
422
435
  await this.database.execute('PRAGMA RECURSIVE_TRIGGERS=TRUE');
436
+ await this.triggersImpl.cleanupResources();
423
437
  this.ready = true;
424
438
  this.iterateListeners((cb) => cb.initialized?.());
425
439
  }
426
440
 
427
- private async _loadVersion() {
441
+ protected async loadVersion() {
428
442
  try {
429
443
  const { version } = await this.database.get<{ version: string }>('SELECT powersync_rs_version() as version');
430
444
  this.sdkVersion = version;
@@ -560,7 +574,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
560
574
 
561
575
  const { clearLocal } = options;
562
576
 
563
- // TODO DB name, verify this is necessary with extension
564
577
  await this.database.writeTransaction(async (tx) => {
565
578
  await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]);
566
579
  });
@@ -597,6 +610,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
597
610
  return;
598
611
  }
599
612
 
613
+ this.triggersImpl.dispose();
614
+
600
615
  await this.iterateAsyncListeners(async (cb) => cb.closing?.());
601
616
 
602
617
  const { disconnect } = options;
@@ -1,4 +1,5 @@
1
1
  import { ILogger } from 'js-logger';
2
+ import { SyncStatus } from '../db/crud/SyncStatus.js';
2
3
  import { BaseListener, BaseObserver } from '../utils/BaseObserver.js';
3
4
  import { PowerSyncBackendConnector } from './connection/PowerSyncBackendConnector.js';
4
5
  import {
@@ -13,7 +14,6 @@ import {
13
14
  SyncStreamSubscribeOptions,
14
15
  SyncStreamSubscription
15
16
  } from './sync/sync-streams.js';
16
- import { SyncStatus } from '../db/crud/SyncStatus.js';
17
17
 
18
18
  /**
19
19
  * @internal
@@ -573,7 +573,11 @@ export abstract class AbstractRemote {
573
573
 
574
574
  const stream = new DataStream<T, string>({
575
575
  logger: this.logger,
576
- mapLine: mapLine
576
+ mapLine: mapLine,
577
+ pressure: {
578
+ highWaterMark: 20,
579
+ lowWaterMark: 10
580
+ }
577
581
  });
578
582
 
579
583
  abortSignal?.addEventListener('abort', () => {
@@ -585,46 +589,54 @@ export abstract class AbstractRemote {
585
589
  let buffer = '';
586
590
 
587
591
 
592
+ const consumeStream = async () => {
593
+ while (!stream.closed && !abortSignal?.aborted && !readerReleased) {
594
+ const { done, value } = await reader.read();
595
+ if (done) {
596
+ const remaining = buffer.trim();
597
+ if (remaining.length != 0) {
598
+ stream.enqueueData(remaining);
599
+ }
588
600
 
589
- const l = stream.registerListener({
590
- lowWater: async () => {
591
- if (stream.closed || abortSignal?.aborted || readerReleased) {
592
- return
593
- }
594
- try {
595
- let didCompleteLine = false;
596
- while (!didCompleteLine) {
597
- const { done, value } = await reader.read();
598
- if (done) {
599
- const remaining = buffer.trim();
600
- if (remaining.length != 0) {
601
- stream.enqueueData(remaining);
602
- }
601
+ stream.close();
602
+ await closeReader();
603
+ return;
604
+ }
603
605
 
604
- stream.close();
605
- await closeReader();
606
- return;
607
- }
606
+ const data = decoder.decode(value, { stream: true });
607
+ buffer += data;
608
608
 
609
- const data = decoder.decode(value, { stream: true });
610
- buffer += data;
609
+ const lines = buffer.split('\n');
610
+ for (var i = 0; i < lines.length - 1; i++) {
611
+ var l = lines[i].trim();
612
+ if (l.length > 0) {
613
+ stream.enqueueData(l);
614
+ }
615
+ }
611
616
 
612
- const lines = buffer.split('\n');
613
- for (var i = 0; i < lines.length - 1; i++) {
614
- var l = lines[i].trim();
615
- if (l.length > 0) {
616
- stream.enqueueData(l);
617
- didCompleteLine = true;
617
+ buffer = lines[lines.length - 1];
618
+
619
+ // Implement backpressure by waiting for the low water mark to be reached
620
+ if (stream.dataQueue.length > stream.highWatermark) {
621
+ await new Promise<void>((resolve) => {
622
+ const dispose = stream.registerListener({
623
+ lowWater: async () => {
624
+ resolve();
625
+ dispose();
626
+ },
627
+ closed: () => {
628
+ resolve();
629
+ dispose();
618
630
  }
619
- }
620
-
621
- buffer = lines[lines.length - 1];
622
- }
623
- } catch (ex) {
624
- stream.close();
625
- throw ex;
631
+ })
632
+ })
626
633
  }
627
- },
634
+ }
635
+ }
636
+
637
+ consumeStream().catch(ex => this.logger.error('Error consuming stream', ex));
638
+
639
+ const l = stream.registerListener({
628
640
  closed: () => {
629
641
  closeReader();
630
642
  l?.();
@@ -47,18 +47,17 @@ export enum SyncClientImplementation {
47
47
  *
48
48
  * This is the default option.
49
49
  *
50
- * @deprecated Don't use {@link SyncClientImplementation.JAVASCRIPT} directly. Instead, use
51
- * {@link DEFAULT_SYNC_CLIENT_IMPLEMENTATION} or omit the option. The explicit choice to use
52
- * the JavaScript-based sync implementation will be removed from a future version of the SDK.
50
+ * @deprecated We recommend the {@link RUST} client implementation for all apps. If you have issues with
51
+ * the Rust client, please file an issue or reach out to us. The JavaScript client will be removed in a future
52
+ * version of the PowerSync SDK.
53
53
  */
54
54
  JAVASCRIPT = 'js',
55
55
  /**
56
56
  * This implementation offloads the sync line decoding and handling into the PowerSync
57
57
  * core extension.
58
58
  *
59
- * @experimental
60
- * While this implementation is more performant than {@link SyncClientImplementation.JAVASCRIPT},
61
- * it has seen less real-world testing and is marked as __experimental__ at the moment.
59
+ * This option is more performant than the {@link JAVASCRIPT} client, enabled by default and the
60
+ * recommended client implementation for all apps.
62
61
  *
63
62
  * ## Compatibility warning
64
63
  *
@@ -77,13 +76,9 @@ export enum SyncClientImplementation {
77
76
  }
78
77
 
79
78
  /**
80
- * The default {@link SyncClientImplementation} to use.
81
- *
82
- * Please use this field instead of {@link SyncClientImplementation.JAVASCRIPT} directly. A future version
83
- * of the PowerSync SDK will enable {@link SyncClientImplementation.RUST} by default and remove the JavaScript
84
- * option.
79
+ * The default {@link SyncClientImplementation} to use, {@link SyncClientImplementation.RUST}.
85
80
  */
86
- export const DEFAULT_SYNC_CLIENT_IMPLEMENTATION = SyncClientImplementation.JAVASCRIPT;
81
+ export const DEFAULT_SYNC_CLIENT_IMPLEMENTATION = SyncClientImplementation.RUST;
87
82
 
88
83
  /**
89
84
  * Abstract Lock to be implemented by various JS environments
@@ -194,8 +189,7 @@ export interface RequiredAdditionalConnectionOptions extends Required<Additional
194
189
  }
195
190
 
196
191
  export interface StreamingSyncImplementation
197
- extends BaseObserverInterface<StreamingSyncImplementationListener>,
198
- Disposable {
192
+ extends BaseObserverInterface<StreamingSyncImplementationListener>, Disposable {
199
193
  /**
200
194
  * Connects to the sync service
201
195
  */
@@ -693,6 +687,9 @@ The next upload iteration will be delayed.`);
693
687
  if (rawTables != null && rawTables.length) {
694
688
  this.logger.warn('Raw tables require the Rust-based sync client. The JS client will ignore them.');
695
689
  }
690
+ if (this.activeStreams.length) {
691
+ this.logger.error('Sync streams require `clientImplementation: SyncClientImplementation.RUST` when connecting.');
692
+ }
696
693
 
697
694
  this.logger.debug('Streaming sync iteration started');
698
695
  this.options.adapter.startSession();
@@ -1241,7 +1238,6 @@ The next upload iteration will be delayed.`);
1241
1238
  resolve();
1242
1239
  return;
1243
1240
  }
1244
-
1245
1241
  const { retryDelayMs } = this.options;
1246
1242
 
1247
1243
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -0,0 +1,25 @@
1
+ import { TriggerClaimManager } from './TriggerManager.js';
2
+
3
+ const CLAIM_STORE = new Map<string, () => Promise<void>>();
4
+
5
+ /**
6
+ * @internal
7
+ * @experimental
8
+ */
9
+ export const MEMORY_TRIGGER_CLAIM_MANAGER: TriggerClaimManager = {
10
+ async obtainClaim(identifier: string): Promise<() => Promise<void>> {
11
+ if (CLAIM_STORE.has(identifier)) {
12
+ throw new Error(`A claim is already present for ${identifier}`);
13
+ }
14
+ const release = async () => {
15
+ CLAIM_STORE.delete(identifier);
16
+ };
17
+ CLAIM_STORE.set(identifier, release);
18
+
19
+ return release;
20
+ },
21
+
22
+ async checkClaim(identifier: string): Promise<boolean> {
23
+ return CLAIM_STORE.has(identifier);
24
+ }
25
+ };
@@ -47,8 +47,9 @@ export interface BaseTriggerDiffRecord<TOperationId extends string | number = nu
47
47
  * This record contains the new value and optionally the previous value.
48
48
  * Values are stored as JSON strings.
49
49
  */
50
- export interface TriggerDiffUpdateRecord<TOperationId extends string | number = number>
51
- extends BaseTriggerDiffRecord<TOperationId> {
50
+ export interface TriggerDiffUpdateRecord<
51
+ TOperationId extends string | number = number
52
+ > extends BaseTriggerDiffRecord<TOperationId> {
52
53
  operation: DiffTriggerOperation.UPDATE;
53
54
  /**
54
55
  * The updated state of the row in JSON string format.
@@ -65,8 +66,9 @@ export interface TriggerDiffUpdateRecord<TOperationId extends string | number =
65
66
  * Represents a diff record for a SQLite INSERT operation.
66
67
  * This record contains the new value represented as a JSON string.
67
68
  */
68
- export interface TriggerDiffInsertRecord<TOperationId extends string | number = number>
69
- extends BaseTriggerDiffRecord<TOperationId> {
69
+ export interface TriggerDiffInsertRecord<
70
+ TOperationId extends string | number = number
71
+ > extends BaseTriggerDiffRecord<TOperationId> {
70
72
  operation: DiffTriggerOperation.INSERT;
71
73
  /**
72
74
  * The value of the row, at the time of INSERT, in JSON string format.
@@ -79,8 +81,9 @@ export interface TriggerDiffInsertRecord<TOperationId extends string | number =
79
81
  * Represents a diff record for a SQLite DELETE operation.
80
82
  * This record contains the new value represented as a JSON string.
81
83
  */
82
- export interface TriggerDiffDeleteRecord<TOperationId extends string | number = number>
83
- extends BaseTriggerDiffRecord<TOperationId> {
84
+ export interface TriggerDiffDeleteRecord<
85
+ TOperationId extends string | number = number
86
+ > extends BaseTriggerDiffRecord<TOperationId> {
84
87
  operation: DiffTriggerOperation.DELETE;
85
88
  /**
86
89
  * The value of the row, before the DELETE operation, in JSON string format.
@@ -201,6 +204,12 @@ interface BaseCreateDiffTriggerOptions {
201
204
  * Hooks which allow execution during the trigger creation process.
202
205
  */
203
206
  hooks?: TriggerCreationHooks;
207
+
208
+ /**
209
+ * Use storage-backed (non-TEMP) tables and triggers that persist across sessions.
210
+ * These resources are still automatically disposed when no longer claimed.
211
+ */
212
+ useStorage?: boolean;
204
213
  }
205
214
 
206
215
  /**
@@ -449,3 +458,38 @@ export interface TriggerManager {
449
458
  */
450
459
  trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;
451
460
  }
461
+
462
+ /**
463
+ * @experimental
464
+ * @internal
465
+ * Manages claims on persisted SQLite triggers and destination tables to enable proper cleanup
466
+ * when they are no longer actively in use.
467
+ *
468
+ * When using persisted triggers (especially for OPFS multi-tab scenarios), we need a reliable way to determine which resources are still actively in use across different connections/tabs so stale resources can be safely cleaned up without interfering with active triggers.
469
+ *
470
+ * A cleanup process runs
471
+ * on database creation (and every 2 minutes) that:
472
+ * 1. Queries for existing managed persisted resources
473
+ * 2. Checks with the claim manager if any consumer is actively using those resources
474
+ * 3. Deletes unused resources
475
+ */
476
+
477
+ export interface TriggerClaimManager {
478
+ /**
479
+ * Obtains or marks a claim on a certain identifier.
480
+ * @returns a callback to release the claim.
481
+ */
482
+ obtainClaim: (identifier: string) => Promise<() => Promise<void>>;
483
+ /**
484
+ * Checks if a claim is present for an identifier.
485
+ */
486
+ checkClaim: (identifier: string) => Promise<boolean>;
487
+ }
488
+
489
+ /**
490
+ * @experimental
491
+ * @internal
492
+ */
493
+ export interface TriggerManagerConfig {
494
+ claimManager: TriggerClaimManager;
495
+ }
@@ -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();