Skip to content

Commit fe3d3d2

Browse files
committed
chore: use new approach
1 parent d326ef4 commit fe3d3d2

File tree

9 files changed

+142
-70
lines changed

9 files changed

+142
-70
lines changed

packages/web/src/db/PowerSyncDatabase.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
WebStreamingSyncImplementation,
2929
WebStreamingSyncImplementationOptions
3030
} from './sync/WebStreamingSyncImplementation';
31-
import { sdkNavigator } from '../shared/navigator';
31+
import { getNavigationLocks } from '../shared/navigator';
3232

3333
export interface WebPowerSyncFlags extends WebSQLFlags {
3434
/**
@@ -161,7 +161,7 @@ export class PowerSyncDatabase extends AbstractPowerSyncDatabase {
161161
if (this.resolvedFlags.ssrMode) {
162162
return PowerSyncDatabase.SHARED_MUTEX.runExclusive(cb);
163163
}
164-
return sdkNavigator.locks.request(`lock-${this.database.name}`, cb);
164+
return getNavigationLocks().request(`lock-${this.database.name}`, cb);
165165
}
166166

167167
protected generateSyncStreamImplementation(connector: PowerSyncBackendConnector): StreamingSyncImplementation {

packages/web/src/db/adapters/wa-sqlite/WASQLiteDBAdapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { DBFunctionsInterface, OpenDB } from '../../../shared/types';
1515
import { _openDB } from '../../../shared/open-db';
1616
import { getWorkerDatabaseOpener, resolveWorkerDatabasePortFactory } from '../../../worker/db/open-worker-database';
1717
import { ResolvedWebSQLOpenOptions, resolveWebSQLFlags, WebSQLFlags } from '../web-sql-flags';
18-
import { sdkNavigator } from '../../../shared/navigator';
18+
import { getNavigationLocks } from '../../../shared/navigator';
1919

2020
/**
2121
* These flags are the same as {@link WebSQLFlags}.
@@ -187,7 +187,7 @@ export class WASQLiteDBAdapter extends BaseObserver<DBAdapterListener> implement
187187
}
188188

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

193193
async readTransaction<T>(fn: (tx: Transaction) => Promise<T>, options?: DBLockOptions | undefined): Promise<T> {

packages/web/src/db/adapters/web-sql-flags.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { SQLOpenOptions } from '@powersync/common';
2-
import { sdkNavigator } from '../../shared/navigator';
32

43
/**
54
* Common settings used when creating SQL connections on web.
@@ -73,7 +72,7 @@ export const DEFAULT_WEB_SQL_FLAGS: ResolvedWebSQLFlags = {
7372
enableMultiTabs:
7473
typeof globalThis.navigator !== 'undefined' && // For SSR purposes
7574
typeof SharedWorker !== 'undefined' &&
76-
!sdkNavigator.userAgent.match(/(Android|iPhone|iPod|iPad)/i) &&
75+
!navigator.userAgent.match(/(Android|iPhone|iPod|iPad)/i) &&
7776
!(window as any).safari,
7877
useWebWorker: true
7978
};

packages/web/src/db/sync/WebStreamingSyncImplementation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
LockType
66
} from '@powersync/common';
77
import { ResolvedWebSQLOpenOptions, WebSQLFlags } from '../adapters/web-sql-flags';
8-
import { sdkNavigator } from '../../shared/navigator';
8+
import { getNavigationLocks } from '../../shared/navigator';
99

1010
export interface WebStreamingSyncImplementationOptions extends AbstractStreamingSyncImplementationOptions {
1111
flags?: WebSQLFlags;
@@ -33,6 +33,6 @@ export class WebStreamingSyncImplementation extends AbstractStreamingSyncImpleme
3333
obtainLock<T>(lockOptions: LockOptions<T>): Promise<T> {
3434
const identifier = `streaming-sync-${lockOptions.type}-${this.webOptions.identifier}`;
3535
lockOptions.type == LockType.SYNC && console.debug('requesting lock for ', identifier);
36-
return sdkNavigator.locks.request(identifier, { signal: lockOptions.signal }, lockOptions.callback);
36+
return getNavigationLocks().request(identifier, { signal: lockOptions.signal }, lockOptions.callback);
3737
}
3838
}
Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,50 @@
1-
class SDKNavigator extends Navigator {
2-
private static instance: SDKNavigator | null = null;
1+
import { Mutex } from 'async-mutex';
32

4-
constructor() {
5-
super();
6-
Object.setPrototypeOf(this, SDKNavigator.prototype);
3+
export const getNavigationLocks = (): LockManager => {
4+
if ('locks' in navigator && navigator.locks) {
5+
return navigator.locks;
76
}
7+
console.warn('Navigator locks are not available in this context.' +
8+
'This may be due to running in an unsecure context. ' +
9+
'Consider using HTTPS or a secure context for full functionality.' +
10+
'Using fallback implementation.');
811

9-
public static getInstance(): SDKNavigator {
10-
if (!SDKNavigator.instance) {
11-
SDKNavigator.instance = new SDKNavigator();
12-
}
13-
return SDKNavigator.instance;
14-
}
12+
const mutexes = new Map<string, Mutex>();
1513

16-
get locks(): LockManager {
17-
if (!super.locks) {
18-
throw new Error('Navigator locks are not available in this context. ' +
19-
'This may be due to running in an unsecure context. ' +
20-
'Consider using HTTPS or a secure context for full functionality.');
14+
const getMutex = (name: string): Mutex => {
15+
if (!mutexes.has(name)) {
16+
mutexes.set(name, new Mutex());
2117
}
22-
return new Proxy(super.locks, {
23-
get(target: LockManager, prop: keyof LockManager) {
24-
return target[prop];
18+
return mutexes.get(name)!;
19+
};
20+
21+
const fallbackLockManager: LockManager = {
22+
request: async (
23+
name: string,
24+
optionsOrCallback: LockOptions | LockGrantedCallback,
25+
maybeCallback?: LockGrantedCallback
26+
): Promise<LockManagerSnapshot> => {
27+
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback!;
28+
const options: LockOptions = typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
29+
30+
const mutex = getMutex(name);
31+
const release = await mutex.acquire();
32+
try {
33+
const lock: Lock = { name, mode: options.mode || 'exclusive' };
34+
return await callback(lock);
35+
} finally {
36+
release();
37+
mutexes.delete(name);
2538
}
26-
});
27-
}
28-
}
39+
},
40+
41+
query: async (): Promise<LockManagerSnapshot> => {
42+
return {
43+
held: Array.from(mutexes.keys()).map(name => ({ name, mode: 'exclusive' as const })),
44+
pending: [] // We can't accurately track pending locks in this implementation as this requires a queue
45+
};
46+
}
47+
};
2948

30-
export const sdkNavigator = SDKNavigator.getInstance();
49+
return fallbackLockManager;
50+
}

packages/web/src/worker/db/WASQLiteDB.worker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import '@journeyapps/wa-sqlite';
66
import * as Comlink from 'comlink';
77
import { _openDB } from '../../shared/open-db';
88
import type { DBFunctionsInterface } from '../../shared/types';
9-
import { sdkNavigator } from '../../shared/navigator';
9+
import { getNavigationLocks } from '../../shared/navigator';
1010

1111
/**
1212
* Keeps track of open DB connections and the clients which
@@ -24,7 +24,7 @@ let nextClientId = 1;
2424

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

3030
if (!DBMap.has(dbFileName)) {

packages/web/src/worker/sync/SharedSyncImplementation.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
import { WASQLiteDBAdapter } from '../../db/adapters/wa-sqlite/WASQLiteDBAdapter';
2424
import { AbstractSharedSyncClientProvider } from './AbstractSharedSyncClientProvider';
2525
import { BroadcastLogger } from './BroadcastLogger';
26-
import { sdkNavigator } from '../../shared/navigator';
26+
import { getNavigationLocks } from '../../shared/navigator';
2727

2828
/**
2929
* Manual message events for shared sync clients
@@ -166,7 +166,7 @@ export class SharedSyncImplementation
166166
async connect(options?: PowerSyncConnectionOptions) {
167167
await this.waitForReady();
168168
// This effectively queues connect and disconnect calls. Ensuring multiple tabs' requests are synchronized
169-
return sdkNavigator.locks.request('shared-sync-connect', async () => {
169+
return getNavigationLocks().request('shared-sync-connect', async () => {
170170
this.syncStreamClient = this.generateStreamingImplementation();
171171

172172
this.syncStreamClient.registerListener({
@@ -182,7 +182,7 @@ export class SharedSyncImplementation
182182
async disconnect() {
183183
await this.waitForReady();
184184
// This effectively queues connect and disconnect calls. Ensuring multiple tabs' requests are synchronized
185-
return sdkNavigator.locks.request('shared-sync-connect', async () => {
185+
return getNavigationLocks().request('shared-sync-connect', async () => {
186186
await this.syncStreamClient?.disconnect();
187187
await this.syncStreamClient?.dispose();
188188
this.syncStreamClient = null;

packages/web/tests/main.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { AbstractPowerSyncDatabase } from '@powersync/common';
33
import { v4 as uuid } from 'uuid';
44
import { TestDatabase, generateTestDb } from './utils/testDb';
@@ -64,4 +64,18 @@ describe('Basic', () => {
6464
expect(result[2].name).equals('Chris');
6565
});
6666
});
67+
68+
describe('navigator.locks fallback', () => {
69+
itWithDBs('should work with PowerSync when navigator.locks is not available', async (db) => {
70+
// This test assumes that PowerSync uses getNavigationLocks internally
71+
// You may need to modify PowerSync to use getNavigationLocks if it doesn't already
72+
//@ts-ignore
73+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
74+
const testName = 'LockTest';
75+
await db.execute('INSERT INTO customers (id, name) VALUES(?, ?)', [uuid(), testName]);
76+
const result = await db.get<TestDatabase['customers']>('SELECT * FROM customers');
77+
78+
expect(result.name).equals(testName);
79+
});
80+
});
6781
});
Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,87 @@
1-
import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest';
2-
import { sdkNavigator } from '../../src/shared/navigator';
3-
4-
describe('sdkNavigator', () => {
5-
let originalNavigator: Navigator;
6-
7-
beforeEach(() => {
8-
originalNavigator = global.navigator;
9-
vi.stubGlobal('navigator', {
10-
...originalNavigator,
11-
locks: {
12-
request: vi.fn(),
13-
},
14-
});
15-
});
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { getNavigationLocks } from '../../src/shared/navigator';
163

4+
describe('getNavigationLocks', () => {
175
afterEach(() => {
18-
vi.stubGlobal('navigator', originalNavigator);
6+
vi.restoreAllMocks();
197
});
208

21-
test('should inherit properties from Navigator', () => {
22-
expect(sdkNavigator.userAgent).toBe(navigator.userAgent);
9+
it('should return native navigator.locks if available', () => {
10+
const mockLocks = {
11+
request: vi.fn(),
12+
query: vi.fn(),
13+
};
14+
15+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(mockLocks);
16+
17+
const result = getNavigationLocks();
18+
expect(result).toBe(mockLocks);
2319
});
2420

25-
test('should have locks property', () => {
26-
expect(sdkNavigator.locks).toBeDefined();
27-
expect(typeof sdkNavigator.locks.request).toBe('function');
21+
it('should return fallback implementation if navigator.locks is not available', () => {
22+
// @ts-ignore
23+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
24+
25+
const result = getNavigationLocks();
26+
expect(result).toHaveProperty('request');
27+
expect(result).toHaveProperty('query');
28+
expect(result).not.toBe(navigator.locks);
2829
});
2930

30-
test('should throw error when locks are not available', () => {
31-
vi.stubGlobal('navigator', { ...originalNavigator, locks: undefined });
32-
expect(() => sdkNavigator.locks).toThrowError('Navigator locks are not available in this context.');
31+
it('fallback request should acquire and release a lock', async () => {
32+
// @ts-ignore
33+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
34+
const locks = getNavigationLocks();
35+
36+
const mockCallback = vi.fn().mockResolvedValue('result');
37+
const result = await locks.request('test-lock', mockCallback);
38+
39+
expect(mockCallback).toHaveBeenCalledWith(expect.objectContaining({
40+
name: 'test-lock',
41+
mode: 'exclusive'
42+
}));
43+
expect(result).toBe('result');
3344
});
3445

35-
test('locks proxy should pass through method calls when locks are available', () => {
36-
const mockCallback = vi.fn();
37-
sdkNavigator.locks.request('test', mockCallback);
38-
expect(navigator.locks.request).toHaveBeenCalledWith('test', mockCallback);
46+
it('fallback query should return held locks', async () => {
47+
// @ts-ignore
48+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
49+
const locks = getNavigationLocks();
50+
51+
// Acquire a lock first
52+
await locks.request('test-lock', async () => {
53+
const queryResult = await locks.query();
54+
expect(queryResult.held).toHaveLength(1);
55+
expect(queryResult.held![0]).toEqual(expect.objectContaining({
56+
name: 'test-lock',
57+
mode: 'exclusive'
58+
}));
59+
expect(queryResult.pending).toHaveLength(0);
60+
});
61+
62+
const finalQueryResult = await locks.query();
63+
expect(finalQueryResult.held).toHaveLength(0);
3964
});
4065

41-
test('should only expose expected Navigator properties', () => {
42-
const sdkNavigatorKeys = Object.keys(sdkNavigator);
43-
const navigatorKeys = Object.keys(navigator);
44-
sdkNavigatorKeys.forEach(key => {
45-
expect(navigatorKeys).toContain(key);
66+
it('fallback implementation should handle concurrent requests', async () => {
67+
// @ts-ignore
68+
vi.spyOn(navigator, 'locks', 'get').mockReturnValue(undefined);
69+
const locks = getNavigationLocks();
70+
71+
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
72+
73+
const request1 = locks.request('test-lock', async () => {
74+
await delay(200);
75+
return 'first';
4676
});
77+
78+
const request2 = locks.request('test-lock', async () => {
79+
return 'second';
80+
});
81+
82+
const [result1, result2] = await Promise.all([request1, request2]);
83+
84+
expect(result1).toBe('first');
85+
expect(result2).toBe('second');
4786
});
4887
});

0 commit comments

Comments
 (0)