Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion packages/web/src/db/PowerSyncDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
WebStreamingSyncImplementation,
WebStreamingSyncImplementationOptions
} from './sync/WebStreamingSyncImplementation';
import { getNavigationLocks } from '../shared/navigator';

export interface WebPowerSyncFlags extends WebSQLFlags {
/**
Expand Down Expand Up @@ -160,7 +161,7 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase {
if (this.resolvedFlags.ssrMode) {
return PowerSyncDatabase.SHARED_MUTEX.runExclusive(cb);
}
return navigator.locks.request(`lock-${this.database.name}`, cb);
return getNavigationLocks().request(`lock-${this.database.name}`, cb);
}

protected generateSyncStreamImplementation(connector: PowerSyncBackendConnector): StreamingSyncImplementation {
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { DBFunctionsInterface, OpenDB } from '../../../shared/types';
import { _openDB } from '../../../shared/open-db';
import { getWorkerDatabaseOpener, resolveWorkerDatabasePortFactory } from '../../../worker/db/open-worker-database';
import { ResolvedWebSQLOpenOptions, resolveWebSQLFlags, WebSQLFlags } from '../web-sql-flags';
import { getNavigationLocks } from '../../../shared/navigator';

/**
* These flags are the same as {@link WebSQLFlags}.
Expand Down Expand Up @@ -186,7 +187,7 @@ export class WASQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
}

protected acquireLock(callback: () => Promise<any>): Promise<any> {
return navigator.locks.request(`db-lock-${this.options.dbFilename}`, callback);
return getNavigationLocks().request(`db-lock-${this.options.dbFilename}`, callback);
}

async readTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/db/sync/WebStreamingSyncImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
LockType
} from '@powersync/common';
import { ResolvedWebSQLOpenOptions, WebSQLFlags } from '../adapters/web-sql-flags';
import { getNavigationLocks } from '../../shared/navigator';

export interface WebStreamingSyncImplementationOptions extends AbstractStreamingSyncImplementationOptions {
flags?: WebSQLFlags;
Expand Down Expand Up @@ -32,6 +33,6 @@ export class WebStreamingSyncImplementation extends AbstractStreamingSyncImpleme
obtainLock<T>(lockOptions: LockOptions<T>): Promise<T> {
const identifier = `streaming-sync-${lockOptions.type}-${this.webOptions.identifier}`;
lockOptions.type == LockType.SYNC && console.debug('requesting lock for ', identifier);
return navigator.locks.request(identifier, { signal: lockOptions.signal }, lockOptions.callback);
return getNavigationLocks().request(identifier, { signal: lockOptions.signal }, lockOptions.callback);
}
}
50 changes: 50 additions & 0 deletions packages/web/src/shared/navigator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Mutex } from 'async-mutex';

export const getNavigationLocks = (): LockManager => {
if ('locks' in navigator && navigator.locks) {
return navigator.locks;
}
console.warn('Navigator locks are not available in this context.' +
'This may be due to running in an unsecure context. ' +
'Consider using HTTPS or a secure context for full functionality.' +
'Using fallback implementation.');

const mutexes = new Map<string, Mutex>();

const getMutex = (name: string): Mutex => {
if (!mutexes.has(name)) {
mutexes.set(name, new Mutex());
}
return mutexes.get(name)!;
};

const fallbackLockManager: LockManager = {
request: async (
name: string,
optionsOrCallback: LockOptions | LockGrantedCallback,
maybeCallback?: LockGrantedCallback
): Promise<LockManagerSnapshot> => {
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
const options: LockOptions = typeof optionsOrCallback === 'object' ? optionsOrCallback : {};

const mutex = getMutex(name);
const release = await mutex.acquire();
try {
const lock: Lock = { name, mode: options.mode || 'exclusive' };
return await callback(lock);
} finally {
release();
mutexes.delete(name);
}
},

query: async (): Promise<LockManagerSnapshot> => {
return {
held: Array.from(mutexes.keys()).map(name => ({ name, mode: 'exclusive' as const })),
pending: [] // We can't accurately track pending locks in this implementation as this requires a queue
};
}
};

return fallbackLockManager;
}
3 changes: 2 additions & 1 deletion packages/web/src/worker/db/WASQLiteDB.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import '@journeyapps/wa-sqlite';
import * as Comlink from 'comlink';
import { _openDB } from '../../shared/open-db';
import type { DBFunctionsInterface } from '../../shared/types';
import { getNavigationLocks } from '../../shared/navigator';

/**
* Keeps track of open DB connections and the clients which
Expand All @@ -23,7 +24,7 @@ let nextClientId = 1;

const openDBShared = async (dbFileName: string): Promise<DBFunctionsInterface> => {
// Prevent multiple simultaneous opens from causing race conditions
return navigator.locks.request(OPEN_DB_LOCK, async () => {
return getNavigationLocks().request(OPEN_DB_LOCK, async () => {
const clientId = nextClientId++;

if (!DBMap.has(dbFileName)) {
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/worker/sync/SharedSyncImplementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { WASQLiteDBAdapter } from '../../db/adapters/wa-sqlite/WASQLiteDBAdapter';
import { AbstractSharedSyncClientProvider } from './AbstractSharedSyncClientProvider';
import { BroadcastLogger } from './BroadcastLogger';
import { getNavigationLocks } from '../../shared/navigator';

/**
* Manual message events for shared sync clients
Expand Down Expand Up @@ -165,7 +166,7 @@ export class SharedSyncImplementation
async connect(options?: PowerSyncConnectionOptions) {
await this.waitForReady();
// This effectively queues connect and disconnect calls. Ensuring multiple tabs' requests are synchronized
return navigator.locks.request('shared-sync-connect', async () => {
return getNavigationLocks().request('shared-sync-connect', async () => {
this.syncStreamClient = this.generateStreamingImplementation();

this.syncStreamClient.registerListener({
Expand All @@ -181,7 +182,7 @@ export class SharedSyncImplementation
async disconnect() {
await this.waitForReady();
// This effectively queues connect and disconnect calls. Ensuring multiple tabs' requests are synchronized
return navigator.locks.request('shared-sync-connect', async () => {
return getNavigationLocks().request('shared-sync-connect', async () => {
await this.syncStreamClient?.disconnect();
await this.syncStreamClient?.dispose();
this.syncStreamClient = null;
Expand Down
16 changes: 15 additions & 1 deletion packages/web/tests/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AbstractPowerSyncDatabase } from '@powersync/common';
import { v4 as uuid } from 'uuid';
import { TestDatabase, generateTestDb } from './utils/testDb';
Expand Down Expand Up @@ -64,4 +64,18 @@ describe('Basic', () => {
expect(result[2].name).equals('Chris');
});
});

describe('navigator.locks fallback', () => {
itWithDBs('should work with PowerSync when navigator.locks is not available', async (db) => {
// This test assumes that PowerSync uses getNavigationLocks internally
// You may need to modify PowerSync to use getNavigationLocks if it doesn't already
//@ts-ignore
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
const testName = 'LockTest';
await db.execute('INSERT INTO customers (id, name) VALUES(?, ?)', [uuid(), testName]);
const result = await db.get<TestDatabase['customers']>('SELECT * FROM customers');

expect(result.name).equals(testName);
});
});
});
87 changes: 87 additions & 0 deletions packages/web/tests/shared/navigator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getNavigationLocks } from '../../src/shared/navigator';

describe('getNavigationLocks', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should return native navigator.locks if available', () => {
const mockLocks = {
request: vi.fn(),
query: vi.fn(),
};

vi.spyOn(navigator, 'locks', 'get').mockReturnValue(mockLocks);

const result = getNavigationLocks();
expect(result).toBe(mockLocks);
});

it('should return fallback implementation if navigator.locks is not available', () => {
// @ts-ignore
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);

const result = getNavigationLocks();
expect(result).toHaveProperty('request');
expect(result).toHaveProperty('query');
expect(result).not.toBe(navigator.locks);
});

it('fallback request should acquire and release a lock', async () => {
// @ts-ignore
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
const locks = getNavigationLocks();

const mockCallback = vi.fn().mockResolvedValue('result');
const result = await locks.request('test-lock', mockCallback);

expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({
name: 'test-lock',
mode: 'exclusive'
}));
expect(result).toBe('result');
});

it('fallback query should return held locks', async () => {
// @ts-ignore
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
const locks = getNavigationLocks();

// Acquire a lock first
await locks.request('test-lock', async () => {
const queryResult = await locks.query();
expect(queryResult.held).toHaveLength(1);
expect(queryResult.held![0]).toEqual(expect.objectContaining({
name: 'test-lock',
mode: 'exclusive'
}));
expect(queryResult.pending).toHaveLength(0);
});

const finalQueryResult = await locks.query();
expect(finalQueryResult.held).toHaveLength(0);
});

it('fallback implementation should handle concurrent requests', async () => {
// @ts-ignore
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
const locks = getNavigationLocks();

const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const request1 = locks.request('test-lock', async () => {
await delay(200);
return 'first';
});

const request2 = locks.request('test-lock', async () => {
return 'second';
});

const [result1, result2] = await Promise.all([request1, request2]);

expect(result1).toBe('first');
expect(result2).toBe('second');
});
});
Loading