@powersync/web 0.0.0-dev-20251106124255 → 0.0.0-dev-20251119142638

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/index.umd.js +39 -17
  2. package/dist/index.umd.js.map +1 -1
  3. package/dist/worker/SharedSyncImplementation.umd.js +35 -27
  4. package/dist/worker/SharedSyncImplementation.umd.js.map +1 -1
  5. package/dist/worker/WASQLiteDB.umd.js +220 -54
  6. package/dist/worker/WASQLiteDB.umd.js.map +1 -1
  7. package/lib/package.json +2 -2
  8. package/lib/src/db/adapters/AsyncDatabaseConnection.d.ts +2 -0
  9. package/lib/src/db/adapters/LockedAsyncDatabaseAdapter.d.ts +1 -1
  10. package/lib/src/db/adapters/LockedAsyncDatabaseAdapter.js +8 -17
  11. package/lib/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.d.ts +2 -0
  12. package/lib/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.js +6 -0
  13. package/lib/src/db/adapters/wa-sqlite/WASQLiteConnection.d.ts +17 -0
  14. package/lib/src/db/adapters/wa-sqlite/WASQLiteConnection.js +25 -0
  15. package/lib/src/worker/db/SharedWASQLiteConnection.d.ts +41 -0
  16. package/lib/src/worker/db/SharedWASQLiteConnection.js +89 -0
  17. package/lib/src/worker/db/WASQLiteDB.worker.js +22 -39
  18. package/lib/src/worker/db/WorkerWASQLiteConnection.d.ts +9 -0
  19. package/lib/src/worker/db/WorkerWASQLiteConnection.js +24 -0
  20. package/lib/tsconfig.tsbuildinfo +1 -1
  21. package/package.json +3 -3
  22. package/src/db/adapters/AsyncDatabaseConnection.ts +2 -0
  23. package/src/db/adapters/LockedAsyncDatabaseAdapter.ts +19 -40
  24. package/src/db/adapters/WorkerWrappedAsyncDatabaseConnection.ts +8 -0
  25. package/src/db/adapters/wa-sqlite/WASQLiteConnection.ts +37 -0
  26. package/src/worker/db/SharedWASQLiteConnection.ts +127 -0
  27. package/src/worker/db/WASQLiteDB.worker.ts +25 -54
  28. package/src/worker/db/WorkerWASQLiteConnection.ts +29 -0
package/lib/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powersync/web",
3
- "version": "1.28.0",
3
+ "version": "1.28.2",
4
4
  "description": "PowerSync web SDK. Sync Postgres, MongoDB or MySQL with SQLite in your web app",
5
5
  "main": "lib/src/index.js",
6
6
  "types": "lib/src/index.d.ts",
@@ -62,7 +62,7 @@
62
62
  "license": "Apache-2.0",
63
63
  "peerDependencies": {
64
64
  "@journeyapps/wa-sqlite": "^1.3.2",
65
- "@powersync/common": "workspace:^1.41.0"
65
+ "@powersync/common": "workspace:^1.42.0"
66
66
  },
67
67
  "dependencies": {
68
68
  "@powersync/common": "workspace:*",
@@ -22,6 +22,8 @@ export type OnTableChangeCallback = (event: BatchedUpdateNotification) => void;
22
22
  export interface AsyncDatabaseConnection<Config extends ResolvedWebSQLOpenOptions = ResolvedWebSQLOpenOptions> {
23
23
  init(): Promise<void>;
24
24
  close(): Promise<void>;
25
+ markHold(): Promise<string>;
26
+ releaseHold(holdId: string): Promise<void>;
25
27
  execute(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
26
28
  executeRaw(sql: string, params?: any[]): Promise<any[][]>;
27
29
  executeBatch(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
@@ -1,4 +1,4 @@
1
- import { type ILogger, BaseObserver, DBAdapterListener, DBLockOptions, LockContext, QueryResult, Transaction } from '@powersync/common';
1
+ import { BaseObserver, DBAdapterListener, DBLockOptions, LockContext, QueryResult, Transaction, type ILogger } from '@powersync/common';
2
2
  import { AsyncDatabaseConnection } from './AsyncDatabaseConnection';
3
3
  import { SharedConnectionWorker, WebDBAdapter } from './WebDBAdapter';
4
4
  import { ResolvedWebSQLOpenOptions } from './web-sql-flags';
@@ -35,15 +35,11 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
35
35
  const start = performance.now();
36
36
  try {
37
37
  const r = await originalExecute(sql, bindings);
38
- const duration = performance.now() - start;
39
38
  performance.measure(`[SQL] ${sql}`, { start });
40
- console.log('%c[SQL] %c%s %c%s', 'color: grey; font-weight: normal', durationStyle(duration), `[${duration.toFixed(1)}ms]`, 'color: grey; font-weight: normal', sql);
41
39
  return r;
42
40
  }
43
41
  catch (e) {
44
- const duration = performance.now() - start;
45
42
  performance.measure(`[SQL] [ERROR: ${e.message}] ${sql}`, { start });
46
- console.error('%c[SQL] %c%s %c%s %c%s', 'color: grey; font-weight: normal', 'color: red; font-weight: normal', `[ERROR: ${e.message}]`, durationStyle(duration), `[${duration.toFixed(1)}ms]`, 'color: grey; font-weight: normal', sql);
47
43
  throw e;
48
44
  }
49
45
  };
@@ -164,12 +160,18 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
164
160
  this.pendingAbortControllers.delete(abortController);
165
161
  }, timeoutMs)
166
162
  : null;
167
- return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, { signal: abortController.signal }, () => {
163
+ return getNavigatorLocks().request(`db-lock-${this._dbIdentifier}`, { signal: abortController.signal }, async () => {
168
164
  this.pendingAbortControllers.delete(abortController);
169
165
  if (timoutId) {
170
166
  clearTimeout(timoutId);
171
167
  }
172
- return callback();
168
+ const holdId = await this.baseDB.markHold();
169
+ try {
170
+ return await callback();
171
+ }
172
+ finally {
173
+ await this.baseDB.releaseHold(holdId);
174
+ }
173
175
  });
174
176
  }
175
177
  async readTransaction(fn, options) {
@@ -283,14 +285,3 @@ export class LockedAsyncDatabaseAdapter extends BaseObserver {
283
285
  };
284
286
  };
285
287
  }
286
- function durationStyle(duration) {
287
- if (duration < 30) {
288
- return 'color: grey; font-weight: normal';
289
- }
290
- else if (duration < 300) {
291
- return 'color: blue; font-weight: normal';
292
- }
293
- else {
294
- return 'color: red; font-weight: normal';
295
- }
296
- }
@@ -34,6 +34,8 @@ export declare class WorkerWrappedAsyncDatabaseConnection<Config extends Resolve
34
34
  * throw on all outstanding promises and forbid new calls.
35
35
  */
36
36
  markRemoteClosed(): void;
37
+ markHold(): Promise<string>;
38
+ releaseHold(holdId: string): Promise<void>;
37
39
  private withRemote;
38
40
  /**
39
41
  * Get a MessagePort which can be used to share the internals of this connection.
@@ -31,6 +31,12 @@ export class WorkerWrappedAsyncDatabaseConnection {
31
31
  // set.
32
32
  this.notifyRemoteClosed.abort();
33
33
  }
34
+ markHold() {
35
+ return this.baseConnection.markHold();
36
+ }
37
+ releaseHold(holdId) {
38
+ return this.baseConnection.releaseHold(holdId);
39
+ }
34
40
  withRemote(workerPromise) {
35
41
  const controller = this.notifyRemoteClosed;
36
42
  if (controller) {
@@ -23,6 +23,14 @@ export type WASQLiteBroadCastTableUpdateEvent = {
23
23
  */
24
24
  export type WASQLiteConnectionListener = {
25
25
  tablesUpdated: (event: BatchedUpdateNotification) => void;
26
+ /**
27
+ * Triggered when an active hold is overwritten by a new hold.
28
+ * This is most likely to happen when a shared connection has been closed
29
+ * without releasing the hold.
30
+ * This listener can be used to cleanup any resources associated with the previous hold.
31
+ * @param holdId - The id of the hold that has been overwritten.
32
+ */
33
+ holdOverwritten: (holdId: string) => Promise<void>;
26
34
  };
27
35
  /**
28
36
  * @internal
@@ -94,9 +102,18 @@ export declare class WASqliteConnection extends BaseObserver<WASQLiteConnectionL
94
102
  * notification loops.
95
103
  */
96
104
  protected connectionId: number;
105
+ protected _holdCounter: number;
106
+ protected _holdId: string | null;
97
107
  constructor(options: ResolvedWASQLiteOpenFactoryOptions);
108
+ /**
109
+ * Gets the id for the current hold.
110
+ * This can be used to check for invalid states.
111
+ */
112
+ get currentHoldId(): string | null;
98
113
  protected get sqliteAPI(): SQLiteAPI;
99
114
  protected get dbP(): number;
115
+ markHold(): Promise<string>;
116
+ releaseHold(holdId: string): Promise<void>;
100
117
  protected openDB(): Promise<number>;
101
118
  protected executeEncryptionPragma(): Promise<void>;
102
119
  protected openSQLiteAPI(): Promise<SQLiteAPI>;
@@ -107,6 +107,8 @@ export class WASqliteConnection extends BaseObserver {
107
107
  * notification loops.
108
108
  */
109
109
  connectionId;
110
+ _holdCounter;
111
+ _holdId;
110
112
  constructor(options) {
111
113
  super();
112
114
  this.options = options;
@@ -116,6 +118,15 @@ export class WASqliteConnection extends BaseObserver {
116
118
  this.connectionId = new Date().valueOf() + Math.random();
117
119
  this.statementMutex = new Mutex();
118
120
  this._moduleFactory = DEFAULT_MODULE_FACTORIES[this.options.vfs];
121
+ this._holdCounter = 0;
122
+ this._holdId = null;
123
+ }
124
+ /**
125
+ * Gets the id for the current hold.
126
+ * This can be used to check for invalid states.
127
+ */
128
+ get currentHoldId() {
129
+ return this._holdId;
119
130
  }
120
131
  get sqliteAPI() {
121
132
  if (!this._sqliteAPI) {
@@ -129,6 +140,20 @@ export class WASqliteConnection extends BaseObserver {
129
140
  }
130
141
  return this._dbP;
131
142
  }
143
+ async markHold() {
144
+ const previousHoldId = this._holdId;
145
+ this._holdId = `${++this._holdCounter}`;
146
+ if (previousHoldId) {
147
+ await this.iterateAsyncListeners(async (cb) => cb.holdOverwritten?.(previousHoldId));
148
+ }
149
+ return this._holdId;
150
+ }
151
+ async releaseHold(holdId) {
152
+ if (holdId != this._holdId) {
153
+ throw new Error(`Invalid hold state, expected ${this._holdId} but got ${holdId}`);
154
+ }
155
+ this._holdId = null;
156
+ }
132
157
  async openDB() {
133
158
  this._dbP = await this.sqliteAPI.open_v2(this.options.dbFilename);
134
159
  return this._dbP;
@@ -0,0 +1,41 @@
1
+ import { ILogger } from '@powersync/common';
2
+ import { AsyncDatabaseConnection, OnTableChangeCallback, ProxiedQueryResult } from '../../db/adapters/AsyncDatabaseConnection';
3
+ import { ResolvedWebSQLOpenOptions } from '../../db/adapters/web-sql-flags';
4
+ /**
5
+ * Keeps track of open DB connections and the clients which
6
+ * are using it.
7
+ */
8
+ export type SharedDBWorkerConnection = {
9
+ clientIds: Set<number>;
10
+ db: AsyncDatabaseConnection;
11
+ };
12
+ export type SharedWASQLiteConnectionOptions = {
13
+ dbMap: Map<string, SharedDBWorkerConnection>;
14
+ dbFilename: string;
15
+ clientId: number;
16
+ logger: ILogger;
17
+ };
18
+ export declare class SharedWASQLiteConnection implements AsyncDatabaseConnection {
19
+ protected options: SharedWASQLiteConnectionOptions;
20
+ protected isClosing: boolean;
21
+ protected activeHoldId: string | null;
22
+ constructor(options: SharedWASQLiteConnectionOptions);
23
+ init(): Promise<void>;
24
+ markHold(): Promise<string>;
25
+ releaseHold(id: string): Promise<void>;
26
+ protected get logger(): ILogger;
27
+ protected get dbEntry(): SharedDBWorkerConnection;
28
+ protected get connection(): AsyncDatabaseConnection<ResolvedWebSQLOpenOptions>;
29
+ protected get clientIds(): Set<number>;
30
+ /**
31
+ * Handles closing of a shared connection.
32
+ * The connection is only closed if there are no active clients using it.
33
+ */
34
+ close(): Promise<void>;
35
+ protected withClosing<T>(action: () => Promise<T>): Promise<T>;
36
+ execute(sql: string, params?: any[]): Promise<ProxiedQueryResult>;
37
+ executeRaw(sql: string, params?: any[]): Promise<any[][]>;
38
+ executeBatch(sql: string, params?: any[] | undefined): Promise<ProxiedQueryResult>;
39
+ registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void>;
40
+ getConfig(): Promise<ResolvedWebSQLOpenOptions>;
41
+ }
@@ -0,0 +1,89 @@
1
+ export class SharedWASQLiteConnection {
2
+ options;
3
+ isClosing;
4
+ // Keeps track if this current hold if the shared connection has a hold
5
+ activeHoldId;
6
+ constructor(options) {
7
+ this.options = options;
8
+ // Add this client ID to the set of known clients
9
+ this.clientIds.add(options.clientId);
10
+ this.isClosing = false;
11
+ this.activeHoldId = null;
12
+ }
13
+ async init() {
14
+ // No-op since the connection is already initialized when it was created
15
+ }
16
+ async markHold() {
17
+ this.activeHoldId = await this.connection.markHold();
18
+ return this.activeHoldId;
19
+ }
20
+ async releaseHold(id) {
21
+ try {
22
+ await this.connection.releaseHold(id);
23
+ }
24
+ finally {
25
+ this.activeHoldId = null;
26
+ }
27
+ }
28
+ get logger() {
29
+ return this.options.logger;
30
+ }
31
+ get dbEntry() {
32
+ return this.options.dbMap.get(this.options.dbFilename);
33
+ }
34
+ get connection() {
35
+ return this.dbEntry.db;
36
+ }
37
+ get clientIds() {
38
+ return this.dbEntry.clientIds;
39
+ }
40
+ /**
41
+ * Handles closing of a shared connection.
42
+ * The connection is only closed if there are no active clients using it.
43
+ */
44
+ async close() {
45
+ // This prevents further statements on this connection from being executed
46
+ this.isClosing = true;
47
+ const { clientIds, logger } = this;
48
+ const { clientId, dbFilename, dbMap } = this.options;
49
+ logger.debug(`Close requested from client ${clientId} of ${[...clientIds]}`);
50
+ clientIds.delete(clientId);
51
+ if (this.activeHoldId) {
52
+ /**
53
+ * The hold hasn't been released, but we're closing now.
54
+ * We can proactively cleanup and release the hold.
55
+ */
56
+ await this.connection.execute('ROLLBACK').catch(() => { });
57
+ await this.connection.releaseHold(this.activeHoldId).catch(() => { });
58
+ }
59
+ if (clientIds.size == 0) {
60
+ logger.debug(`Closing connection to ${this.options}.`);
61
+ const connection = this.connection;
62
+ dbMap.delete(dbFilename);
63
+ await connection.close();
64
+ }
65
+ logger.debug(`Connection to ${dbFilename} not closed yet due to active clients.`);
66
+ return;
67
+ }
68
+ async withClosing(action) {
69
+ if (this.isClosing) {
70
+ throw new Error('Connection is closing');
71
+ }
72
+ return action();
73
+ }
74
+ async execute(sql, params) {
75
+ return this.withClosing(() => this.connection.execute(sql, params));
76
+ }
77
+ async executeRaw(sql, params) {
78
+ return this.withClosing(() => this.connection.executeRaw(sql, params));
79
+ }
80
+ executeBatch(sql, params) {
81
+ return this.withClosing(() => this.connection.executeBatch(sql, params));
82
+ }
83
+ registerOnTableChange(callback) {
84
+ return this.connection.registerOnTableChange(callback);
85
+ }
86
+ getConfig() {
87
+ return this.connection.getConfig();
88
+ }
89
+ }
@@ -4,29 +4,15 @@
4
4
  import '@journeyapps/wa-sqlite';
5
5
  import { createBaseLogger, createLogger } from '@powersync/common';
6
6
  import * as Comlink from 'comlink';
7
- import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
8
7
  import { getNavigatorLocks } from '../../shared/navigator';
8
+ import { SharedWASQLiteConnection } from './SharedWASQLiteConnection';
9
+ import { WorkerWASQLiteConnection, proxyWASQLiteConnection } from './WorkerWASQLiteConnection';
9
10
  const baseLogger = createBaseLogger();
10
11
  baseLogger.useDefaults();
11
12
  const logger = createLogger('db-worker');
12
13
  const DBMap = new Map();
13
14
  const OPEN_DB_LOCK = 'open-wasqlite-db';
14
15
  let nextClientId = 1;
15
- const openWorkerConnection = async (options) => {
16
- const connection = new WASqliteConnection(options);
17
- return {
18
- init: Comlink.proxy(() => connection.init()),
19
- getConfig: Comlink.proxy(() => connection.getConfig()),
20
- close: Comlink.proxy(() => connection.close()),
21
- execute: Comlink.proxy(async (sql, params) => connection.execute(sql, params)),
22
- executeRaw: Comlink.proxy(async (sql, params) => connection.executeRaw(sql, params)),
23
- executeBatch: Comlink.proxy(async (sql, params) => connection.executeBatch(sql, params)),
24
- registerOnTableChange: Comlink.proxy(async (callback) => {
25
- // Proxy the callback remove function
26
- return Comlink.proxy(await connection.registerOnTableChange(callback));
27
- })
28
- };
29
- };
30
16
  const openDBShared = async (options) => {
31
17
  // Prevent multiple simultaneous opens from causing race conditions
32
18
  return getNavigatorLocks().request(OPEN_DB_LOCK, async () => {
@@ -35,35 +21,32 @@ const openDBShared = async (options) => {
35
21
  logger.setLevel(logLevel);
36
22
  if (!DBMap.has(dbFilename)) {
37
23
  const clientIds = new Set();
38
- const connection = await openWorkerConnection(options);
24
+ // This format returns proxy objects for function callbacks
25
+ const connection = new WorkerWASQLiteConnection(options);
39
26
  await connection.init();
27
+ connection.registerListener({
28
+ holdOverwritten: async () => {
29
+ /**
30
+ * The previous hold has been overwritten, without being released.
31
+ * we need to cleanup any resources associated with it.
32
+ * We can perform a rollback to release any potential transactions that were started.
33
+ */
34
+ await connection.execute('ROLLBACK').catch(() => { });
35
+ }
36
+ });
40
37
  DBMap.set(dbFilename, {
41
38
  clientIds,
42
39
  db: connection
43
40
  });
44
41
  }
45
- const dbEntry = DBMap.get(dbFilename);
46
- dbEntry.clientIds.add(clientId);
47
- const { db } = dbEntry;
48
- const wrappedConnection = {
49
- ...db,
50
- init: Comlink.proxy(async () => {
51
- // the init has been done automatically
52
- }),
53
- close: Comlink.proxy(async () => {
54
- const { clientIds } = dbEntry;
55
- logger.debug(`Close requested from client ${clientId} of ${[...clientIds]}`);
56
- clientIds.delete(clientId);
57
- if (clientIds.size == 0) {
58
- logger.debug(`Closing connection to ${dbFilename}.`);
59
- DBMap.delete(dbFilename);
60
- return db.close?.();
61
- }
62
- logger.debug(`Connection to ${dbFilename} not closed yet due to active clients.`);
63
- return;
64
- })
65
- };
66
- return Comlink.proxy(wrappedConnection);
42
+ // Associates this clientId with the shared connection entry
43
+ const sharedConnection = new SharedWASQLiteConnection({
44
+ dbMap: DBMap,
45
+ dbFilename,
46
+ clientId,
47
+ logger
48
+ });
49
+ return proxyWASQLiteConnection(sharedConnection);
67
50
  });
68
51
  };
69
52
  // Check if we're in a SharedWorker context
@@ -0,0 +1,9 @@
1
+ import { AsyncDatabaseConnection, OnTableChangeCallback } from '../../db/adapters/AsyncDatabaseConnection';
2
+ import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
3
+ /**
4
+ * Fully proxies a WASQLiteConnection to be used as an AsyncDatabaseConnection.
5
+ */
6
+ export declare function proxyWASQLiteConnection(connection: AsyncDatabaseConnection): AsyncDatabaseConnection;
7
+ export declare class WorkerWASQLiteConnection extends WASqliteConnection {
8
+ registerOnTableChange(callback: OnTableChangeCallback): Promise<() => void>;
9
+ }
@@ -0,0 +1,24 @@
1
+ import * as Comlink from 'comlink';
2
+ import { WASqliteConnection } from '../../db/adapters/wa-sqlite/WASQLiteConnection';
3
+ /**
4
+ * Fully proxies a WASQLiteConnection to be used as an AsyncDatabaseConnection.
5
+ */
6
+ export function proxyWASQLiteConnection(connection) {
7
+ return Comlink.proxy({
8
+ init: Comlink.proxy(() => connection.init()),
9
+ close: Comlink.proxy(() => connection.close()),
10
+ markHold: Comlink.proxy(() => connection.markHold()),
11
+ releaseHold: Comlink.proxy((holdId) => connection.releaseHold(holdId)),
12
+ execute: Comlink.proxy((sql, params) => connection.execute(sql, params)),
13
+ executeRaw: Comlink.proxy((sql, params) => connection.executeRaw(sql, params)),
14
+ executeBatch: Comlink.proxy((sql, params) => connection.executeBatch(sql, params)),
15
+ registerOnTableChange: Comlink.proxy((callback) => connection.registerOnTableChange(callback)),
16
+ getConfig: Comlink.proxy(() => connection.getConfig())
17
+ });
18
+ }
19
+ export class WorkerWASQLiteConnection extends WASqliteConnection {
20
+ async registerOnTableChange(callback) {
21
+ // Proxy the callback remove function
22
+ return Comlink.proxy(await super.registerOnTableChange(callback));
23
+ }
24
+ }