Skip to content

Conversation

@stevensJourney
Copy link
Collaborator

@stevensJourney stevensJourney commented Dec 30, 2025

Overview

Fixes:

Our SQLite trigger helpers currently allow for tracking INSERT, UPDATE, and DELETE operations on a PowerSync view. These operations are stored in a SQLite table for each managed consumer to process.

Our managed triggers currently only support temporary SQLite triggers, where diff operations are stored in temporary SQLite tables. This approach avoids extra storage overhead for diff records and simplifies cleanup. The temporary nature means the triggers and tables are scoped to the SQLite connection and are disposed automatically once the connection is closed.

The Problem

For OPFS (in Web), we create a separate SQLite connection per tab. Triggers are also scoped to each tab, so each tab creates its own managed trigger (hence its own SQLite trigger and destination table). Having multiple connections across multiple tabs causes issues:

A mutation performed by one connection does not trigger a temporary trigger defined for another connection.

This causes our trigger consumers to miss updates from other tabs.

The Solution

Persisted SQLite triggers are stored in the database. A mutation from any connection will trigger all persisted triggers, ensuring that mutations from other tabs are written to the destination table of other tabs' managed triggers.

This PR adds the ability to optionally use persisted SQLite tables and triggers (enabled by default for OPFS connections). While these resources do use persistence, they are still temporary in nature. The PowerSync SDK will dispose of these resources automatically when they are detected as no longer in use.

Implementation

Using persisted triggers and destination tables solves the multiple SQLite connection problem, but introduces a new challenge: how do we dispose of the persisted tables and triggers when they are no longer needed?

The Claim Mechanism

Disposing stale items for closed tabs (and SQLite connections) is not straightforward. When a tab is closed, there is usually no reliable method for ensuring cleanup has completed successfully.

To address this, the implementation introduces a "claim" mechanism through a new TriggerClaimManager interface. This interface provides two core capabilities: obtaining a claim on a resource (returning a release callback), and checking whether a resource is currently claimed.

A heartbeat or time-to-live mechanism was also considered, but this approach seemed vulnerable to slow or frozen tab issues. A tab that's temporarily unresponsive could have its resources incorrectly cleaned up. The hold method avoids this problem since a hold remains valid regardless of how responsive the owning tab is.

The resources and claims to resources are freed if the trigger is disposed using the callback returned from the method which created it.

const dispose = await database.triggers.trackTableDiff({...})

// This will delete the tables
await dispose()

For cases where the triggers are not explicitly disposed, a cleanup process is implemented. This process runs whenever a new PowerSyncDatabase is created and at a 2 minute interval.

A unique UUID is associated with each managed trigger. This ID and destination SQLite table are encoded into the names of the SQLite triggers created. The naming convention for triggers is as follows:

__ps_temp_trigger_${operation}__${destination_table}__${trigger_id}

The cleanup process:

  • queries the sqlite_master table for all triggers which match the __ps_temp_trigger naming convention. The destination table and trigger id are extracted
  • Checks the TriggerHoldManager if any active claims are present for the trigger id
  • Deletes the triggers and tables if no claim is present.

Alternative approaches were considered for the tracking of the managed trigger Ids and tables.

  • Storing metadata in ps_kv: Cant be used since the keys in ps_kvare cleared indisconnectAndClear`, which will discard the state.
  • Adding comments to the trigger sql, which should be present in sqlite_master: Can't be used since the comments are removed when executing (at least for WA-SQLite)

Platform-Specific Claim Managers

The claim mechanism needs to work differently depending on the platform.

For shared-memory SDKs like Node and React Native, a MemoryTriggerClaimManager uses a global in-memory store to track claims. Since these environments typically share memory within a single process, this straightforward approach works well.

For web environments, a NavigatorTriggerClaimManager leverages the Navigator Locks API to manage claims across browser tabs. This allows us to determine if any tab is still holding on to a trigger, even when those tabs can't directly communicate with each other.

The web PowerSyncDatabase enables persistence by default for OPFS VFS modes, while keeping temporary triggers for IDBBatchAtomicVFS where multiple connections aren't an issue.

@changeset-bot
Copy link

changeset-bot bot commented Dec 30, 2025

🦋 Changeset detected

Latest commit: 5ea40a2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
@powersync/common Minor
@powersync/web Minor
@powersync/adapter-sql-js Patch
@powersync/node Patch
@powersync/op-sqlite Patch
@powersync/react-native Patch
@powersync/tanstack-react-query Patch
@powersync/diagnostics-app Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@stevensJourney stevensJourney changed the title [wip] OPFS Multiple Tab Trigger Invocation OPFS Multiple Tab Trigger Invocation Jan 8, 2026
@stevensJourney stevensJourney marked this pull request as ready for review January 8, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants