Skip to content

Commit aaf6037

Browse files
feat: OPFS Multiple Tab Trigger Invocation (#804)
Co-authored-by: Simon Binder <simon@journeyapps.com>
1 parent 8db47f3 commit aaf6037

31 files changed

Lines changed: 833 additions & 137 deletions

.changeset/mighty-keys-compete.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@powersync/common': minor
3+
'@powersync/web': minor
4+
---
5+
6+
Add support for storage-backed (non-TEMP) SQLite triggers and tables for managed triggers. These resources persist on disk while in use and are automatically cleaned up when no longer claimed or needed. They should not be considered permanent triggers; PowerSync manages their lifecycle.

.changeset/shaggy-donuts-boil.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/web': minor
3+
---
4+
5+
Managed triggers now use storage-backed (non-TEMP) SQLite triggers and tables when OPFS is the VFS. Resources persist across tabs and connection cycles to detect cross‑tab changes, and are automatically cleaned up when no longer in use. These should not be treated as permanent triggers; their lifecycle is managed by PowerSync.

demos/react-supabase-todolist-tanstackdb/src/components/providers/SystemProvider.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SupabaseConnector } from '@/library/powersync/SupabaseConnector';
44
import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema';
55
import { CircularProgress } from '@mui/material';
66
import { PowerSyncContext } from '@powersync/react';
7-
import { LogLevel, PowerSyncDatabase, createBaseLogger } from '@powersync/web';
7+
import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web';
88
import { createCollection } from '@tanstack/db';
99
import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection';
1010
import React, { Suspense } from 'react';
@@ -15,9 +15,10 @@ export const useSupabase = () => React.useContext(SupabaseContext);
1515

1616
export const db = new PowerSyncDatabase({
1717
schema: AppSchema,
18-
database: {
19-
dbFilename: 'example.db'
20-
}
18+
database: new WASQLiteOpenFactory({
19+
dbFilename: 'example.db',
20+
vfs: WASQLiteVFS.OPFSCoopSyncVFS
21+
})
2122
});
2223

2324
export const listsCollection = createCollection(

packages/capacitor/src/PowerSyncDatabase.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Capacitor } from '@capacitor/core';
22
import {
33
DBAdapter,
4+
MEMORY_TRIGGER_CLAIM_MANAGER,
45
PowerSyncBackendConnector,
56
RequiredAdditionalConnectionOptions,
67
StreamingSyncImplementation,
8+
TriggerManagerConfig,
79
PowerSyncDatabase as WebPowerSyncDatabase,
810
WebPowerSyncDatabaseOptionsWithSettings,
911
WebRemote
@@ -44,6 +46,18 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase {
4446
}
4547
}
4648

49+
protected generateTriggerManagerConfig(): TriggerManagerConfig {
50+
const config = super.generateTriggerManagerConfig();
51+
if (this.isNativeCapacitorPlatform) {
52+
/**
53+
* We usually only ever have a single tab for capacitor.
54+
* Avoiding navigator locks allows insecure contexts (during development).
55+
*/
56+
config.claimManager = MEMORY_TRIGGER_CLAIM_MANAGER;
57+
}
58+
return config;
59+
}
60+
4761
protected runExclusive<T>(cb: () => Promise<T>): Promise<T> {
4862
if (this.isNativeCapacitorPlatform) {
4963
// Use mutex for mobile platforms.

packages/common/src/client/AbstractPowerSyncDatabase.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import {
4040
} from './sync/stream/AbstractStreamingSyncImplementation.js';
4141
import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.js';
4242
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';
4445
import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js';
4546
import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js';
4647
import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js';
@@ -222,6 +223,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
222223
* Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
223224
*/
224225
readonly triggers: TriggerManager;
226+
protected triggersImpl: TriggerManagerImpl;
225227

226228
logger: ILogger;
227229

@@ -296,9 +298,10 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
296298

297299
this._isReadyPromise = this.initialize();
298300

299-
this.triggers = new TriggerManagerImpl({
301+
this.triggers = this.triggersImpl = new TriggerManagerImpl({
300302
db: this,
301-
schema: this.schema
303+
schema: this.schema,
304+
...this.generateTriggerManagerConfig()
302305
});
303306
}
304307

@@ -334,6 +337,16 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
334337
*/
335338
protected abstract openDBAdapter(options: PowerSyncDatabaseOptionsWithSettings): DBAdapter;
336339

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+
337350
protected abstract generateSyncStreamImplementation(
338351
connector: PowerSyncBackendConnector,
339352
options: CreateSyncImplementationOptions & RequiredAdditionalConnectionOptions
@@ -420,6 +433,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
420433
await this.updateSchema(this.options.schema);
421434
await this.resolveOfflineSyncStatus();
422435
await this.database.execute('PRAGMA RECURSIVE_TRIGGERS=TRUE');
436+
await this.triggersImpl.cleanupResources();
423437
this.ready = true;
424438
this.iterateListeners((cb) => cb.initialized?.());
425439
}
@@ -560,7 +574,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
560574

561575
const { clearLocal } = options;
562576

563-
// TODO DB name, verify this is necessary with extension
564577
await this.database.writeTransaction(async (tx) => {
565578
await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]);
566579
});
@@ -597,6 +610,8 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
597610
return;
598611
}
599612

613+
this.triggersImpl.dispose();
614+
600615
await this.iterateAsyncListeners(async (cb) => cb.closing?.());
601616

602617
const { disconnect } = options;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
};

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

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ export interface BaseTriggerDiffRecord<TOperationId extends string | number = nu
4747
* This record contains the new value and optionally the previous value.
4848
* Values are stored as JSON strings.
4949
*/
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> {
5253
operation: DiffTriggerOperation.UPDATE;
5354
/**
5455
* The updated state of the row in JSON string format.
@@ -65,8 +66,9 @@ export interface TriggerDiffUpdateRecord<TOperationId extends string | number =
6566
* Represents a diff record for a SQLite INSERT operation.
6667
* This record contains the new value represented as a JSON string.
6768
*/
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> {
7072
operation: DiffTriggerOperation.INSERT;
7173
/**
7274
* 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 =
7981
* Represents a diff record for a SQLite DELETE operation.
8082
* This record contains the new value represented as a JSON string.
8183
*/
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> {
8487
operation: DiffTriggerOperation.DELETE;
8588
/**
8689
* The value of the row, before the DELETE operation, in JSON string format.
@@ -201,6 +204,12 @@ interface BaseCreateDiffTriggerOptions {
201204
* Hooks which allow execution during the trigger creation process.
202205
*/
203206
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;
204213
}
205214

206215
/**
@@ -449,3 +458,38 @@ export interface TriggerManager {
449458
*/
450459
trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;
451460
}
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+
}

0 commit comments

Comments
 (0)