Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9637aaf
OPFS Multiple tab triggers
stevensJourney Dec 30, 2025
f66ce86
Merge remote-tracking branch 'origin/main' into multiple-tab-triggers…
stevensJourney Jan 6, 2026
22877b6
cleanup defaults and typing
stevensJourney Jan 6, 2026
fcfcc32
Improve disposal of resources. Add unit tests for disposal.
stevensJourney Jan 7, 2026
24ac097
use trigger name to determine the table name. cleanup resources with …
stevensJourney Jan 8, 2026
dcba009
update trigger naming
stevensJourney Jan 8, 2026
f437400
Add more tests.
stevensJourney Jan 8, 2026
797bde5
cleanup
stevensJourney Jan 8, 2026
d88e630
cleanup naming for TriggerClaimManager
stevensJourney Jan 8, 2026
fa6f129
remove unused items
stevensJourney Jan 8, 2026
7150f96
cleanup naming
stevensJourney Jan 8, 2026
db14b39
add test for default temporary triggers
stevensJourney Jan 8, 2026
5ea40a2
remove debugger statement
stevensJourney Jan 8, 2026
066c39a
Merge remote-tracking branch 'origin/main' into multiple-tab-triggers…
stevensJourney Jan 21, 2026
275134f
updates after updating from main
stevensJourney Jan 21, 2026
392b9d4
Update TriggerManager doc comment
stevensJourney Jan 21, 2026
394f056
use const exports for Memory and Navigator trigger claim managers.
stevensJourney Jan 21, 2026
fba6018
Update packages/common/src/client/triggers/TriggerManagerImpl.ts
stevensJourney Jan 21, 2026
89b4184
Merge branch 'multiple-tab-triggers-opfs' of github.com:journeyapps/p…
stevensJourney Jan 21, 2026
c992cce
Merge branch 'multiple-tab-triggers-opfs' of github.com:journeyapps/p…
stevensJourney Jan 21, 2026
1c88121
cleanup cleanup function to use JS Regex matching
stevensJourney Jan 21, 2026
e03ae52
Update packages/common/src/client/triggers/MemoryTriggerClaimManager.ts
stevensJourney Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/mighty-keys-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@powersync/common': minor
'@powersync/web': minor
---

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.
5 changes: 5 additions & 0 deletions .changeset/shaggy-donuts-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@powersync/web': minor
---

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.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SupabaseConnector } from '@/library/powersync/SupabaseConnector';
import { TodosDeserializationSchema, TodosSchema } from '@/library/powersync/TodosSchema';
import { CircularProgress } from '@mui/material';
import { PowerSyncContext } from '@powersync/react';
import { LogLevel, PowerSyncDatabase, createBaseLogger } from '@powersync/web';
import { createBaseLogger, LogLevel, PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web';
import { createCollection } from '@tanstack/db';
import { powerSyncCollectionOptions } from '@tanstack/powersync-db-collection';
import React, { Suspense } from 'react';
Expand All @@ -15,9 +15,10 @@ export const useSupabase = () => React.useContext(SupabaseContext);

export const db = new PowerSyncDatabase({
schema: AppSchema,
database: {
dbFilename: 'example.db'
}
database: new WASQLiteOpenFactory({
dbFilename: 'example.db',
vfs: WASQLiteVFS.OPFSCoopSyncVFS
})
});

export const listsCollection = createCollection(
Expand Down
14 changes: 14 additions & 0 deletions packages/capacitor/src/PowerSyncDatabase.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Capacitor } from '@capacitor/core';
import {
DBAdapter,
MEMORY_TRIGGER_CLAIM_MANAGER,
PowerSyncBackendConnector,
RequiredAdditionalConnectionOptions,
StreamingSyncImplementation,
TriggerManagerConfig,
PowerSyncDatabase as WebPowerSyncDatabase,
WebPowerSyncDatabaseOptionsWithSettings,
WebRemote
Expand Down Expand Up @@ -44,6 +46,18 @@ export class PowerSyncDatabase extends WebPowerSyncDatabase {
}
}

protected generateTriggerManagerConfig(): TriggerManagerConfig {
const config = super.generateTriggerManagerConfig();
if (this.isNativeCapacitorPlatform) {
/**
* We usually only ever have a single tab for capacitor.
* Avoiding navigator locks allows insecure contexts (during development).
*/
config.claimManager = MEMORY_TRIGGER_CLAIM_MANAGER;
}
return config;
}

protected runExclusive<T>(cb: () => Promise<T>): Promise<T> {
if (this.isNativeCapacitorPlatform) {
// Use mutex for mobile platforms.
Expand Down
23 changes: 19 additions & 4 deletions packages/common/src/client/AbstractPowerSyncDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import {
} from './sync/stream/AbstractStreamingSyncImplementation.js';
import { CoreSyncStatus, coreStatusToJs } from './sync/stream/core-instruction.js';
import { SyncStream } from './sync/sync-streams.js';
import { TriggerManager } from './triggers/TriggerManager.js';
import { MEMORY_TRIGGER_CLAIM_MANAGER } from './triggers/MemoryTriggerClaimManager.js';
import { TriggerManager, TriggerManagerConfig } from './triggers/TriggerManager.js';
import { TriggerManagerImpl } from './triggers/TriggerManagerImpl.js';
import { DEFAULT_WATCH_THROTTLE_MS, WatchCompatibleQuery } from './watched/WatchedQuery.js';
import { OnChangeQueryProcessor } from './watched/processors/OnChangeQueryProcessor.js';
Expand Down Expand Up @@ -222,6 +223,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
* Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
*/
readonly triggers: TriggerManager;
protected triggersImpl: TriggerManagerImpl;

logger: ILogger;

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

this._isReadyPromise = this.initialize();

this.triggers = new TriggerManagerImpl({
this.triggers = this.triggersImpl = new TriggerManagerImpl({
db: this,
schema: this.schema
schema: this.schema,
...this.generateTriggerManagerConfig()
});
}

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

/**
* Generates a base configuration for {@link TriggerManagerImpl}.
* Implementations should override this if necessary.
*/
protected generateTriggerManagerConfig(): TriggerManagerConfig {
return {
claimManager: MEMORY_TRIGGER_CLAIM_MANAGER
};
}

protected abstract generateSyncStreamImplementation(
connector: PowerSyncBackendConnector,
options: CreateSyncImplementationOptions & RequiredAdditionalConnectionOptions
Expand Down Expand Up @@ -420,6 +433,7 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB
await this.updateSchema(this.options.schema);
await this.resolveOfflineSyncStatus();
await this.database.execute('PRAGMA RECURSIVE_TRIGGERS=TRUE');
await this.triggersImpl.cleanupResources();
this.ready = true;
this.iterateListeners((cb) => cb.initialized?.());
}
Expand Down Expand Up @@ -560,7 +574,6 @@ export abstract class AbstractPowerSyncDatabase extends BaseObserver<PowerSyncDB

const { clearLocal } = options;

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

this.triggersImpl.dispose();

await this.iterateAsyncListeners(async (cb) => cb.closing?.());

const { disconnect } = options;
Expand Down
25 changes: 25 additions & 0 deletions packages/common/src/client/triggers/MemoryTriggerClaimManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { TriggerClaimManager } from './TriggerManager.js';

const CLAIM_STORE = new Map<string, () => Promise<void>>();

/**
* @internal
* @experimental
*/
export const MEMORY_TRIGGER_CLAIM_MANAGER: TriggerClaimManager = {
async obtainClaim(identifier: string): Promise<() => Promise<void>> {
if (CLAIM_STORE.has(identifier)) {
throw new Error(`A claim is already present for ${identifier}`);
}
const release = async () => {
CLAIM_STORE.delete(identifier);
};
CLAIM_STORE.set(identifier, release);

return release;
},

async checkClaim(identifier: string): Promise<boolean> {
return CLAIM_STORE.has(identifier);
}
};
56 changes: 50 additions & 6 deletions packages/common/src/client/triggers/TriggerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ export interface BaseTriggerDiffRecord<TOperationId extends string | number = nu
* This record contains the new value and optionally the previous value.
* Values are stored as JSON strings.
*/
export interface TriggerDiffUpdateRecord<TOperationId extends string | number = number>
extends BaseTriggerDiffRecord<TOperationId> {
export interface TriggerDiffUpdateRecord<
TOperationId extends string | number = number
> extends BaseTriggerDiffRecord<TOperationId> {
operation: DiffTriggerOperation.UPDATE;
/**
* The updated state of the row in JSON string format.
Expand All @@ -65,8 +66,9 @@ export interface TriggerDiffUpdateRecord<TOperationId extends string | number =
* Represents a diff record for a SQLite INSERT operation.
* This record contains the new value represented as a JSON string.
*/
export interface TriggerDiffInsertRecord<TOperationId extends string | number = number>
extends BaseTriggerDiffRecord<TOperationId> {
export interface TriggerDiffInsertRecord<
TOperationId extends string | number = number
> extends BaseTriggerDiffRecord<TOperationId> {
operation: DiffTriggerOperation.INSERT;
/**
* The value of the row, at the time of INSERT, in JSON string format.
Expand All @@ -79,8 +81,9 @@ export interface TriggerDiffInsertRecord<TOperationId extends string | number =
* Represents a diff record for a SQLite DELETE operation.
* This record contains the new value represented as a JSON string.
*/
export interface TriggerDiffDeleteRecord<TOperationId extends string | number = number>
extends BaseTriggerDiffRecord<TOperationId> {
export interface TriggerDiffDeleteRecord<
TOperationId extends string | number = number
> extends BaseTriggerDiffRecord<TOperationId> {
operation: DiffTriggerOperation.DELETE;
/**
* The value of the row, before the DELETE operation, in JSON string format.
Expand Down Expand Up @@ -201,6 +204,12 @@ interface BaseCreateDiffTriggerOptions {
* Hooks which allow execution during the trigger creation process.
*/
hooks?: TriggerCreationHooks;

/**
* Use storage-backed (non-TEMP) tables and triggers that persist across sessions.
* These resources are still automatically disposed when no longer claimed.
*/
useStorage?: boolean;
}

/**
Expand Down Expand Up @@ -449,3 +458,38 @@ export interface TriggerManager {
*/
trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;
}

/**
* @experimental
* @internal
* Manages claims on persisted SQLite triggers and destination tables to enable proper cleanup
* when they are no longer actively in use.
*
* 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.
*
* A cleanup process runs
* on database creation (and every 2 minutes) that:
* 1. Queries for existing managed persisted resources
* 2. Checks with the claim manager if any consumer is actively using those resources
* 3. Deletes unused resources
*/

export interface TriggerClaimManager {
/**
* Obtains or marks a claim on a certain identifier.
* @returns a callback to release the claim.
*/
obtainClaim: (identifier: string) => Promise<() => Promise<void>>;
/**
* Checks if a claim is present for an identifier.
*/
checkClaim: (identifier: string) => Promise<boolean>;
}

/**
* @experimental
* @internal
*/
export interface TriggerManagerConfig {
claimManager: TriggerClaimManager;
}
Loading