Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,
MemoryTriggerClaimManager,
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 = new MemoryTriggerClaimManager();
}
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 { MemoryTriggerClaimManager } 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: new MemoryTriggerClaimManager()
};
}

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
26 changes: 26 additions & 0 deletions packages/common/src/client/triggers/MemoryTriggerClaimManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TriggerClaimManager } from './TriggerManager.js';

/**
* @internal
* @experimental
*/
export class MemoryTriggerClaimManager implements TriggerClaimManager {
// Uses a global store to share the state between potentially multiple instances
private static CLAIM_STORE = new Map<string, () => Promise<void>>();

async obtainClaim(identifier: string): Promise<() => Promise<void>> {
if (MemoryTriggerClaimManager.CLAIM_STORE.has(identifier)) {
throw new Error(`A claim is already present for ${identifier}`);
}
const release = async () => {
MemoryTriggerClaimManager.CLAIM_STORE.delete(identifier);
};
MemoryTriggerClaimManager.CLAIM_STORE.set(identifier, release);

return release;
}

async checkClaim(identifier: string): Promise<boolean> {
return MemoryTriggerClaimManager.CLAIM_STORE.has(identifier);
}
}
48 changes: 42 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,30 @@ export interface TriggerManager {
*/
trackTableDiff(options: TrackDiffOptions): Promise<TriggerRemoveCallback>;
}

/**
* @experimental
* @internal
* An interface which exposes which persisted managed SQLite triggers and destination SQLite tables
* are actively in use. Resource which are not reported as claimed by this interface will be disposed.
*/

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