diff --git a/.changeset/plenty-goats-relax.md b/.changeset/plenty-goats-relax.md new file mode 100644 index 000000000..e0a7c18c0 --- /dev/null +++ b/.changeset/plenty-goats-relax.md @@ -0,0 +1,6 @@ +--- +'@powersync/common': minor +--- + +Fix `createDiffTrigger` acquiring its own read lock before running setup, even when a `setupContext` was provided. +On platforms where read and write access share a single connection (e.g. web), this deadlocked when `createDiffTrigger` was called inside a write lock. diff --git a/packages/common/api-extractor.json b/packages/common/api-extractor.json index 7078885d1..2218e31d6 100644 --- a/packages/common/api-extractor.json +++ b/packages/common/api-extractor.json @@ -76,7 +76,7 @@ * * DEFAULT VALUE: "crlf" */ - // "newlineKind": "crlf", + "newlineKind": "lf", /** * Specifies how API Extractor sorts members of an enum when generating the .api.json file. By default, the output diff --git a/packages/common/etc/common.api.md b/packages/common/etc/common.api.md index e01008179..f2816502d 100644 --- a/packages/common/etc/common.api.md +++ b/packages/common/etc/common.api.md @@ -2228,7 +2228,7 @@ export class TriggerManagerImpl implements TriggerManager { // (undocumented) protected generateTriggerName(operation: DiffTriggerOperation, destinationTable: string, triggerId: string): string; // (undocumented) - protected getUUID(): Promise; + protected getUUID(ctx?: LockContext): Promise; // (undocumented) protected isDisposed: boolean; // Warning: (ae-forgotten-export) The symbol "TriggerManagerImplOptions" needs to be exported by the entry point index.d.ts diff --git a/packages/common/src/client/triggers/TriggerManagerImpl.ts b/packages/common/src/client/triggers/TriggerManagerImpl.ts index 66f6908a6..5445e07a8 100644 --- a/packages/common/src/client/triggers/TriggerManagerImpl.ts +++ b/packages/common/src/client/triggers/TriggerManagerImpl.ts @@ -97,8 +97,8 @@ export class TriggerManagerImpl implements TriggerManager { return this.options.db; } - protected async getUUID() { - const { id: uuid } = await this.db.get<{ id: string }>(/* sql */ ` + protected async getUUID(ctx?: LockContext) { + const { id: uuid } = await (ctx ?? this.db).get<{ id: string }>(/* sql */ ` SELECT uuid () as id `); @@ -237,7 +237,7 @@ export class TriggerManagerImpl implements TriggerManager { const internalSource = sourceDefinition.internalName; const triggerIds: string[] = []; - const id = await this.getUUID(); + const id = await this.getUUID(setupContext); const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null; diff --git a/packages/web/tests/triggers.test.ts b/packages/web/tests/triggers.test.ts index 585879f5b..677b188e9 100644 --- a/packages/web/tests/triggers.test.ts +++ b/packages/web/tests/triggers.test.ts @@ -190,6 +190,50 @@ describe('Triggers', () => { ); }); + it('should create a trigger inside an already-held write lock via setupContext', async () => { + const db = generateTestDb(); + + const destination = 'temp_setup_context_diff'; + + // Mirrors the on-demand sync path: the caller holds the write lock and passes + // its context in as setupContext. createDiffTrigger must not acquire any + // additional locks, since read and write access share a single connection + // queue on web — a nested lock request deadlocks against the held write lock. + const triggerCreated = db.writeLock(async (tx) => + db.triggers.createDiffTrigger({ + source: TEST_SCHEMA.props.customers.name, + destination, + when: { + [DiffTriggerOperation.INSERT]: 'TRUE' + }, + setupContext: tx + }) + ); + + const dispose = await Promise.race([ + triggerCreated, + new Promise((_, reject) => + setTimeout( + () => + reject( + new Error('Deadlock: createDiffTrigger requested a new lock while the setupContext write lock was held') + ), + 5_000 + ) + ) + ]); + + onTestFinished(() => dispose()); + + // Sanity check that the trigger is functional + await db.execute("INSERT INTO customers (id, name) VALUES (uuid(), 'setup-context')"); + + await vi.waitFor(async () => { + const rows = await db.getAll(`SELECT * FROM ${destination}`); + expect(rows.length).toEqual(1); + }); + }); + it('should report diff operations across clients (insert from client B observed by client A)', async () => { const openDB = (filename: string) => generateTestDb({