@powersync/web 2.1.1 → 2.2.0
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.
- package/dist/index.react_native_web.js +212 -92
- package/dist/index.react_native_web.js.map +1 -1
- package/dist/worker/AccessHandlePoolVFS-BPUHfZME.js.map +1 -1
- package/dist/worker/FacadeVFS-d1ZDvud7.js.map +1 -1
- package/dist/worker/IDBBatchAtomicVFS-DbkDb777.js.map +1 -1
- package/dist/worker/MemoryVFS-DVJL5F8j.js.map +1 -1
- package/dist/worker/OPFSCoopSyncVFS-BgTiWPfa.js.map +1 -1
- package/dist/worker/OPFSWriteAheadVFS-Rt5CGCZP.js +2 -0
- package/dist/worker/OPFSWriteAheadVFS-Rt5CGCZP.js.map +1 -0
- package/dist/worker/mc-wa-sqlite-DDFgWP93.js.map +1 -1
- package/dist/worker/mc-wa-sqlite-async-lGclTjKJ.js.map +1 -1
- package/dist/worker/wa-sqlite-B0tZMM0j.js.map +1 -1
- package/dist/worker/wa-sqlite-async-CM6BmfRh.js.map +1 -1
- package/dist/worker/worker.js +1 -1
- package/dist/worker/worker.js.map +1 -1
- package/lib/db/PowerSyncDatabase.js +16 -11
- package/lib/db/PowerSyncDatabase.js.map +1 -1
- package/lib/db/adapters/AsyncWebAdapter.js +4 -55
- package/lib/db/adapters/AsyncWebAdapter.js.map +1 -1
- package/lib/db/adapters/acquireFromPool.d.ts +7 -0
- package/lib/db/adapters/acquireFromPool.js +60 -0
- package/lib/db/adapters/acquireFromPool.js.map +1 -0
- package/lib/db/adapters/memory-pool/client.d.ts +70 -0
- package/lib/db/adapters/memory-pool/client.js +269 -0
- package/lib/db/adapters/memory-pool/client.js.map +1 -0
- package/lib/db/adapters/memory-pool/shared.d.ts +51 -0
- package/lib/db/adapters/memory-pool/shared.js +16 -0
- package/lib/db/adapters/memory-pool/shared.js.map +1 -0
- package/lib/db/adapters/memory-pool/vfs.d.ts +19 -0
- package/lib/db/adapters/memory-pool/vfs.js +247 -0
- package/lib/db/adapters/memory-pool/vfs.js.map +1 -0
- package/lib/db/adapters/memory-pool/worker.d.ts +1 -0
- package/lib/db/adapters/memory-pool/worker.js +45 -0
- package/lib/db/adapters/memory-pool/worker.js.map +1 -0
- package/lib/db/adapters/options.d.ts +7 -0
- package/lib/db/adapters/options.js.map +1 -1
- package/lib/db/adapters/wa-sqlite/RawSqliteConnection.d.ts +8 -0
- package/lib/db/adapters/wa-sqlite/RawSqliteConnection.js +56 -5
- package/lib/db/adapters/wa-sqlite/RawSqliteConnection.js.map +1 -1
- package/lib/db/adapters/wa-sqlite/StatementCache.d.ts +16 -0
- package/lib/db/adapters/wa-sqlite/StatementCache.js +44 -0
- package/lib/db/adapters/wa-sqlite/StatementCache.js.map +1 -0
- package/lib/db/adapters/wa-sqlite/WASQLiteOpenFactory.js +7 -4
- package/lib/db/adapters/wa-sqlite/WASQLiteOpenFactory.js.map +1 -1
- package/lib/worker/db/MultiDatabaseServer.d.ts +1 -2
- package/lib/worker/db/MultiDatabaseServer.js +28 -17
- package/lib/worker/db/MultiDatabaseServer.js.map +1 -1
- package/package.json +13 -6
- package/src/db/PowerSyncDatabase.ts +18 -11
- package/src/db/adapters/AsyncWebAdapter.ts +13 -61
- package/src/db/adapters/acquireFromPool.ts +73 -0
- package/src/db/adapters/memory-pool/client.ts +367 -0
- package/src/db/adapters/memory-pool/shared.ts +79 -0
- package/src/db/adapters/memory-pool/vfs.ts +293 -0
- package/src/db/adapters/memory-pool/worker.ts +53 -0
- package/src/db/adapters/options.ts +8 -0
- package/src/db/adapters/wa-sqlite/RawSqliteConnection.ts +71 -6
- package/src/db/adapters/wa-sqlite/StatementCache.ts +50 -0
- package/src/db/adapters/wa-sqlite/WASQLiteOpenFactory.ts +17 -5
- package/src/worker/db/MultiDatabaseServer.ts +29 -21
- package/dist/worker/OPFSWriteAheadVFS-BzodSqNq.js +0 -2
- package/dist/worker/OPFSWriteAheadVFS-BzodSqNq.js.map +0 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// @ts-ignore
|
|
2
|
+
import { FacadeVFS } from '@journeyapps/wa-sqlite/src/FacadeVFS.js';
|
|
3
|
+
import * as VFS from '@journeyapps/wa-sqlite/src/VFS.js';
|
|
4
|
+
import { emptyWalState, WalIndexChange, WalOverlayEntry, WriteAheadBuffers, WriteAheadState } from './shared.js';
|
|
5
|
+
|
|
6
|
+
const mainDbSentinel = Symbol();
|
|
7
|
+
|
|
8
|
+
export class InMemoryWriteAheadLog extends FacadeVFS {
|
|
9
|
+
// The main /database file is the only file for which we create a write-ahead log. All other files (including the
|
|
10
|
+
// journal) are local-only.
|
|
11
|
+
#openedFiles = new Map<number, LocalFile | typeof mainDbSentinel>();
|
|
12
|
+
#files = new Map<string, LocalFile | typeof mainDbSentinel>();
|
|
13
|
+
|
|
14
|
+
#tx: WriteAheadTransaction | undefined = undefined;
|
|
15
|
+
#newOverlayPagesToSendToMainTab = new Map<number, WalOverlayEntry>();
|
|
16
|
+
|
|
17
|
+
writeAheadState: WriteAheadState = emptyWalState();
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
module: any,
|
|
21
|
+
readonly buffers: WriteAheadBuffers
|
|
22
|
+
) {
|
|
23
|
+
super('InMemoryWriteAheadLog', module);
|
|
24
|
+
this.#files.set('/database', mainDbSentinel);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
takeChanges(): WalIndexChange {
|
|
28
|
+
const added: (number | WalOverlayEntry)[] = [];
|
|
29
|
+
this.#newOverlayPagesToSendToMainTab.forEach((v, k) => {
|
|
30
|
+
added.push(k);
|
|
31
|
+
added.push(v);
|
|
32
|
+
});
|
|
33
|
+
this.#newOverlayPagesToSendToMainTab.clear();
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
cleared: false,
|
|
37
|
+
walEnd: this.writeAheadState.walEnd,
|
|
38
|
+
fileSize: this.writeAheadState.fileSize,
|
|
39
|
+
added
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
jAccess(zName: string, _pFlags: number, pResOut: DataView) {
|
|
44
|
+
const file = this.#files.get(zName);
|
|
45
|
+
pResOut.setInt32(0, file != null ? 1 : 0, true);
|
|
46
|
+
return VFS.SQLITE_OK;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
jOpen(zName: string | null, fileId: number, flags: number, pOutFlags: DataView): number {
|
|
50
|
+
if (flags & VFS.SQLITE_OPEN_MAIN_DB) {
|
|
51
|
+
this.#openedFiles.set(fileId, mainDbSentinel);
|
|
52
|
+
} else {
|
|
53
|
+
// Treat all other files, including the mostly unused journal as temporary.
|
|
54
|
+
zName ??= `tmp-${crypto.randomUUID()}`;
|
|
55
|
+
|
|
56
|
+
const existing = this.#files.get(zName);
|
|
57
|
+
if (existing != null) {
|
|
58
|
+
this.#openedFiles.set(fileId, existing);
|
|
59
|
+
} else {
|
|
60
|
+
if (!(flags & VFS.SQLITE_OPEN_CREATE)) {
|
|
61
|
+
return VFS.SQLITE_IOERR;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const newFile = new LocalFile(zName);
|
|
65
|
+
newFile.deleteOnClose = (flags & VFS.SQLITE_OPEN_DELETEONCLOSE) != 0;
|
|
66
|
+
this.#openedFiles.set(fileId, newFile);
|
|
67
|
+
this.#files.set(zName, newFile);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pOutFlags.setInt32(0, flags, true);
|
|
72
|
+
return VFS.SQLITE_OK;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
jClose(fileid: number) {
|
|
76
|
+
const file = this.#openedFiles.get(fileid)!;
|
|
77
|
+
this.#openedFiles.delete(fileid);
|
|
78
|
+
|
|
79
|
+
if (file != mainDbSentinel && file.deleteOnClose) {
|
|
80
|
+
this.#files.delete(file.name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return VFS.SQLITE_OK;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
jRead(fileId: number, data: Uint8Array, offset: number) {
|
|
87
|
+
const file = this.#openedFiles.get(fileId)!;
|
|
88
|
+
let endOffset: number;
|
|
89
|
+
let readableBytes: number;
|
|
90
|
+
|
|
91
|
+
if (file == mainDbSentinel) {
|
|
92
|
+
const { fileSize, overlay } = this.writeAheadState;
|
|
93
|
+
const startOffset = Math.min(offset, fileSize);
|
|
94
|
+
endOffset = Math.min(fileSize, offset + data.byteLength);
|
|
95
|
+
readableBytes = endOffset - startOffset;
|
|
96
|
+
|
|
97
|
+
// Try reading from the write-ahead overlay first. A read on the database file is always a complete page, except
|
|
98
|
+
// when reading from the 100-byte header.
|
|
99
|
+
const page = overlay.get(offset < 100 ? 0 : offset);
|
|
100
|
+
|
|
101
|
+
if (page != null) {
|
|
102
|
+
const pageOffset = page.logOffset;
|
|
103
|
+
const source = new Uint8Array(
|
|
104
|
+
this.buffers.writeAheadLog,
|
|
105
|
+
offset < 100 ? pageOffset + offset : pageOffset,
|
|
106
|
+
readableBytes
|
|
107
|
+
);
|
|
108
|
+
data.set(source);
|
|
109
|
+
} else {
|
|
110
|
+
// Page is not in WAL overlay, read directly from underlying in-memory buffer.
|
|
111
|
+
data.set(new Uint8Array(this.buffers.database, startOffset, readableBytes));
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const fileSize = file.size;
|
|
115
|
+
const startOffset = Math.min(offset, fileSize);
|
|
116
|
+
endOffset = Math.min(fileSize, offset + data.byteLength);
|
|
117
|
+
readableBytes = endOffset - startOffset;
|
|
118
|
+
|
|
119
|
+
if (readableBytes > 0) {
|
|
120
|
+
data.set(new Uint8Array(file.buffer, startOffset, readableBytes));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (readableBytes < data.byteLength) {
|
|
125
|
+
data.fill(0, readableBytes); // Fill rest with zeroes.
|
|
126
|
+
return VFS.SQLITE_IOERR_SHORT_READ;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return VFS.SQLITE_OK;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
jWrite(fileId: number, data: Uint8Array, offset: number): number {
|
|
133
|
+
const file = this.#openedFiles.get(fileId)!;
|
|
134
|
+
// wa-sqlite passes a proxy instead of a real Uint8Array.
|
|
135
|
+
const actualBuffer = data.subarray();
|
|
136
|
+
|
|
137
|
+
if (file === mainDbSentinel) {
|
|
138
|
+
// Write to the write-ahead overlay.
|
|
139
|
+
let didWrite = false;
|
|
140
|
+
if (this.#tx != null) {
|
|
141
|
+
didWrite = this.#tx.appendPage(offset, actualBuffer);
|
|
142
|
+
} else {
|
|
143
|
+
const tx = new WriteAheadTransaction(this.writeAheadState.walEnd, this.writeAheadState.fileSize, this.buffers);
|
|
144
|
+
didWrite = tx.appendPage(offset, actualBuffer);
|
|
145
|
+
this.#commit(tx);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return didWrite ? VFS.SQLITE_OK : VFS.SQLITE_FULL;
|
|
149
|
+
} else {
|
|
150
|
+
const writeLength = data.length;
|
|
151
|
+
const endOffset = offset + writeLength;
|
|
152
|
+
if (endOffset > file.buffer.byteLength) {
|
|
153
|
+
const didGrow = file.growBuffer(endOffset);
|
|
154
|
+
if (!didGrow) {
|
|
155
|
+
return VFS.SQLITE_FULL;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
new Uint8Array(file.buffer, offset, writeLength).set(actualBuffer);
|
|
160
|
+
file.size = Math.max(file.size, endOffset);
|
|
161
|
+
return VFS.SQLITE_OK;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
jTruncate(fileid: number, size: number): number {
|
|
166
|
+
const file = this.#openedFiles.get(fileid)!;
|
|
167
|
+
|
|
168
|
+
if (file === mainDbSentinel) {
|
|
169
|
+
this.writeAheadState.fileSize = Math.min(this.writeAheadState.fileSize, size);
|
|
170
|
+
} else {
|
|
171
|
+
file.size = Math.min(size, file.size);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return VFS.SQLITE_OK;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
jFileSize(fileid: number, pSize64: DataView) {
|
|
178
|
+
const file = this.#openedFiles.get(fileid)!;
|
|
179
|
+
|
|
180
|
+
if (file === mainDbSentinel) {
|
|
181
|
+
pSize64.setBigInt64(0, BigInt(this.writeAheadState.fileSize), true);
|
|
182
|
+
} else {
|
|
183
|
+
pSize64.setBigInt64(0, BigInt(file.size), true);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return VFS.SQLITE_OK;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
jDelete(name: string) {
|
|
190
|
+
if (name === '/database') {
|
|
191
|
+
return VFS.SQLITE_IOERR_DELETE;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.#files.delete(name);
|
|
195
|
+
return VFS.SQLITE_OK;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
jDeviceCharacteristics(): number {
|
|
199
|
+
return VFS.SQLITE_IOCAP_UNDELETABLE_WHEN_OPEN | VFS.SQLITE_IOCAP_BATCH_ATOMIC;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
jFileControl(fileid: number, op: number) {
|
|
203
|
+
const file = this.#openedFiles.get(fileid)!;
|
|
204
|
+
if (file !== mainDbSentinel) return VFS.SQLITE_NOTFOUND;
|
|
205
|
+
|
|
206
|
+
switch (op) {
|
|
207
|
+
case VFS.SQLITE_FCNTL_BEGIN_ATOMIC_WRITE:
|
|
208
|
+
this.#tx = new WriteAheadTransaction(this.writeAheadState.walEnd, this.writeAheadState.fileSize, this.buffers);
|
|
209
|
+
break;
|
|
210
|
+
case VFS.SQLITE_FCNTL_ROLLBACK_ATOMIC_WRITE:
|
|
211
|
+
this.#tx = undefined;
|
|
212
|
+
break;
|
|
213
|
+
case VFS.SQLITE_FCNTL_COMMIT_ATOMIC_WRITE:
|
|
214
|
+
this.#commit(this.#tx!);
|
|
215
|
+
this.#tx = undefined;
|
|
216
|
+
break;
|
|
217
|
+
default:
|
|
218
|
+
return VFS.SQLITE_NOTFOUND;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return VFS.SQLITE_OK;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#commit(tx: WriteAheadTransaction) {
|
|
225
|
+
this.writeAheadState.walEnd = tx.walEndOffset;
|
|
226
|
+
this.writeAheadState.fileSize = tx.fileSize;
|
|
227
|
+
tx.changedPages.forEach((walEntry, databaseOffset) => {
|
|
228
|
+
this.#newOverlayPagesToSendToMainTab.set(databaseOffset, walEntry);
|
|
229
|
+
this.writeAheadState.overlay.set(databaseOffset, walEntry);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
class WriteAheadTransaction {
|
|
235
|
+
walEndOffset: number;
|
|
236
|
+
fileSize: number;
|
|
237
|
+
changedPages = new Map<number, WalOverlayEntry>();
|
|
238
|
+
|
|
239
|
+
constructor(
|
|
240
|
+
endOffset: number,
|
|
241
|
+
fileSize: number,
|
|
242
|
+
readonly buffers: WriteAheadBuffers
|
|
243
|
+
) {
|
|
244
|
+
this.walEndOffset = endOffset;
|
|
245
|
+
this.fileSize = fileSize;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
appendPage(offset: number, data: Uint8Array): boolean {
|
|
249
|
+
const currentEnd = this.walEndOffset;
|
|
250
|
+
const newEnd = currentEnd + data.length;
|
|
251
|
+
|
|
252
|
+
const wal = this.buffers.writeAheadLog;
|
|
253
|
+
if (newEnd > wal.byteLength) {
|
|
254
|
+
if (!this.#growWal(wal, newEnd)) return false;
|
|
255
|
+
}
|
|
256
|
+
this.fileSize = Math.max(this.fileSize, offset + data.length);
|
|
257
|
+
new Uint8Array(wal, currentEnd, data.length).set(data);
|
|
258
|
+
|
|
259
|
+
this.walEndOffset = newEnd;
|
|
260
|
+
this.changedPages.set(offset, { logOffset: currentEnd, size: data.length });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#growWal(buffer: SharedArrayBuffer, minSize: number) {
|
|
265
|
+
const newSize = Math.max(minSize, Math.min(2 * buffer.byteLength, buffer.maxByteLength));
|
|
266
|
+
if (newSize > buffer.maxByteLength) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
buffer.grow(newSize);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
class LocalFile {
|
|
276
|
+
deleteOnClose = false;
|
|
277
|
+
name: string;
|
|
278
|
+
buffer = new ArrayBuffer(1024);
|
|
279
|
+
size: number = 0;
|
|
280
|
+
|
|
281
|
+
constructor(name: string) {
|
|
282
|
+
this.name = name;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
growBuffer(minSize: number): boolean {
|
|
286
|
+
const newSize = Math.max(minSize, 2 * this.buffer.byteLength);
|
|
287
|
+
const newBuffer = new ArrayBuffer(newSize);
|
|
288
|
+
|
|
289
|
+
new Uint8Array(newBuffer).set(new Uint8Array(this.buffer));
|
|
290
|
+
this.buffer = newBuffer;
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as Comlink from 'comlink';
|
|
2
|
+
import { applyWalChanges, DatabaseServer, WalIndexChange, WriteAheadBuffers } from './shared.js';
|
|
3
|
+
import { RawQueryResult } from '@powersync/common';
|
|
4
|
+
import { InMemoryWriteAheadLog } from './vfs.js';
|
|
5
|
+
import { RawSqliteConnection, RawWaSqliteDatabaseOptions, RawWebResult } from '../wa-sqlite/RawSqliteConnection.js';
|
|
6
|
+
import { WASQLiteVFS } from '../wa-sqlite/vfs.js';
|
|
7
|
+
import { TemporaryStorageOption } from '../options.js';
|
|
8
|
+
|
|
9
|
+
class MemoryDatabaseServer implements DatabaseServer {
|
|
10
|
+
#vfs!: InMemoryWriteAheadLog;
|
|
11
|
+
#connection!: RawSqliteConnection;
|
|
12
|
+
|
|
13
|
+
async open(buffers: WriteAheadBuffers, options: Partial<RawWaSqliteDatabaseOptions>): Promise<void> {
|
|
14
|
+
const { default: factory } = await import('@journeyapps/wa-sqlite/dist/wa-sqlite.mjs');
|
|
15
|
+
const module = await factory();
|
|
16
|
+
const vfs = (this.#vfs = new InMemoryWriteAheadLog(module, buffers));
|
|
17
|
+
|
|
18
|
+
const connection = new RawSqliteConnection({
|
|
19
|
+
encryptionKey: undefined,
|
|
20
|
+
vfs: WASQLiteVFS.InMemoryVfs,
|
|
21
|
+
// Unused, since we register our own VFS. Only needed to satisfy the type.
|
|
22
|
+
temporaryStorage: TemporaryStorageOption.MEMORY,
|
|
23
|
+
cacheSizeKb: 50 * 1024,
|
|
24
|
+
preparedStatementsCache: 0,
|
|
25
|
+
...options,
|
|
26
|
+
// The rest of these options is not overridable: The VFS handles /database reads and writes in a special way and
|
|
27
|
+
// there can only be one database per buffers instance.
|
|
28
|
+
filename: '/database',
|
|
29
|
+
readonly: false
|
|
30
|
+
});
|
|
31
|
+
await connection.initWithModule(module, vfs);
|
|
32
|
+
this.#connection = connection;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async updateWalState(overlay: WalIndexChange): Promise<void> {
|
|
36
|
+
const currentState = this.#vfs.writeAheadState;
|
|
37
|
+
applyWalChanges(currentState, overlay);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async executeRaw(query: string, params?: any[] | undefined): Promise<RawQueryResult> {
|
|
41
|
+
return await this.#connection.execute(query, params);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async executeBatch(query: string, params: any[][]): Promise<RawWebResult[]> {
|
|
45
|
+
return await this.#connection.executeBatch(query, params);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async takeWalChanges(): Promise<WalIndexChange> {
|
|
49
|
+
return this.#vfs.takeChanges();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Comlink.expose(new MemoryDatabaseServer());
|
|
@@ -88,6 +88,14 @@ export interface WebSpecificOpenOptions {
|
|
|
88
88
|
* A worker will be initialized if none is provided
|
|
89
89
|
*/
|
|
90
90
|
workerPort?: MessagePort | undefined;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* If set to a value greater than zero, the worker will cache prepared statements to avoid preparing them every time
|
|
94
|
+
* a query runs.
|
|
95
|
+
*
|
|
96
|
+
* Defaults to 0 (disabling the cache).
|
|
97
|
+
*/
|
|
98
|
+
preparedStatementsCache?: number;
|
|
91
99
|
}
|
|
92
100
|
|
|
93
101
|
export interface ResolvedWebSQLOpenOptions extends SQLOpenOptions, WebSpecificOpenOptions {}
|
|
@@ -4,6 +4,7 @@ import { loadModuleAndVfs, WASQLiteVFS } from './vfs.js';
|
|
|
4
4
|
import { TemporaryStorageOption } from '../options.js';
|
|
5
5
|
import { RawQueryResult, SqliteValue } from '@powersync/common';
|
|
6
6
|
import { maxPathNameLength } from '../resolveAndValidateOptions.js';
|
|
7
|
+
import { PreparedStatementCache } from './StatementCache.js';
|
|
7
8
|
|
|
8
9
|
export interface RawWebResult extends Required<RawQueryResult> {
|
|
9
10
|
autocommit: boolean;
|
|
@@ -24,6 +25,10 @@ export interface RawWaSqliteDatabaseOptions {
|
|
|
24
25
|
encryptionKey: string | undefined;
|
|
25
26
|
temporaryStorage: TemporaryStorageOption;
|
|
26
27
|
cacheSizeKb: number;
|
|
28
|
+
/**
|
|
29
|
+
* The amount of prepared statements to cache, or 0 to disable caching.
|
|
30
|
+
*/
|
|
31
|
+
preparedStatementsCache: number;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
@@ -34,19 +39,30 @@ export interface RawWaSqliteDatabaseOptions {
|
|
|
34
39
|
*/
|
|
35
40
|
export class RawSqliteConnection {
|
|
36
41
|
private _sqliteAPI: SQLiteAPI | null = null;
|
|
42
|
+
private sqlite3_stmt_isexplain!: (stmt: number) => 0 | 1 | 2;
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
45
|
* The `sqlite3*` connection pointer.
|
|
39
46
|
*/
|
|
40
47
|
private db: number = 0;
|
|
48
|
+
private statementCache: PreparedStatementCache | null;
|
|
41
49
|
|
|
42
|
-
constructor(readonly options: RawWaSqliteDatabaseOptions) {
|
|
50
|
+
constructor(readonly options: RawWaSqliteDatabaseOptions) {
|
|
51
|
+
this.statementCache =
|
|
52
|
+
options.preparedStatementsCache > 0 ? new PreparedStatementCache(options.preparedStatementsCache) : null;
|
|
53
|
+
}
|
|
43
54
|
|
|
44
55
|
get isOpen(): boolean {
|
|
45
56
|
return this.db != 0;
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
async init() {
|
|
49
|
-
const
|
|
60
|
+
const { module, vfs } = await loadModuleAndVfs(this.options);
|
|
61
|
+
await this.initWithModule(module, vfs);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async initWithModule(module: any, vfs: any) {
|
|
65
|
+
const api = (this._sqliteAPI = await this.openSQLiteAPI(module, vfs));
|
|
50
66
|
this.db = await api.open_v2(
|
|
51
67
|
this.options.filename,
|
|
52
68
|
this.options.readonly ? 1 /* SQLITE_OPEN_READONLY */ : 6 /* SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE */
|
|
@@ -61,9 +77,10 @@ export class RawSqliteConnection {
|
|
|
61
77
|
await this.executeRaw(`SELECT powersync_update_hooks('install');`);
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
private async openSQLiteAPI(): Promise<SQLiteAPI> {
|
|
65
|
-
const { module, vfs } = await loadModuleAndVfs(this.options);
|
|
80
|
+
private async openSQLiteAPI(module: any, vfs: SQLiteVFS): Promise<SQLiteAPI> {
|
|
66
81
|
vfs.mxPathname = maxPathNameLength;
|
|
82
|
+
|
|
83
|
+
this.sqlite3_stmt_isexplain = module.cwrap('sqlite3_stmt_isexplain', 'int', ['int']);
|
|
67
84
|
const sqlite3 = WaSqliteFactory(module);
|
|
68
85
|
sqlite3.vfs_register(vfs, true);
|
|
69
86
|
/**
|
|
@@ -143,7 +160,7 @@ export class RawSqliteConnection {
|
|
|
143
160
|
async executeRaw(sql: string, bindings?: any[]): Promise<RawResultSet[]> {
|
|
144
161
|
const results = [];
|
|
145
162
|
const api = this.requireSqlite();
|
|
146
|
-
for await (const stmt of
|
|
163
|
+
for await (const stmt of this.cachedStatements(api, sql)) {
|
|
147
164
|
let columns;
|
|
148
165
|
|
|
149
166
|
const rs = await this.stepThroughStatement(api, stmt, bindings ?? [], columns);
|
|
@@ -194,8 +211,56 @@ export class RawSqliteConnection {
|
|
|
194
211
|
|
|
195
212
|
async close() {
|
|
196
213
|
if (this.isOpen) {
|
|
197
|
-
|
|
214
|
+
const api = this.requireSqlite();
|
|
215
|
+
|
|
216
|
+
if (this.statementCache) {
|
|
217
|
+
for (const stmt of this.statementCache.drain()) {
|
|
218
|
+
await api.finalize(stmt);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await api.close(this.db);
|
|
198
223
|
this.db = 0;
|
|
199
224
|
}
|
|
200
225
|
}
|
|
226
|
+
|
|
227
|
+
private async *cachedStatements(api: SQLiteAPI, sql: string): AsyncIterable<number> {
|
|
228
|
+
{
|
|
229
|
+
const existing = this.statementCache?.lookup(sql);
|
|
230
|
+
if (existing != null) {
|
|
231
|
+
yield existing;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const inner = api.statements(this.db, sql, { unscoped: true });
|
|
237
|
+
const preparedStatements: number[] = [];
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
for await (const stmt of inner) {
|
|
241
|
+
preparedStatements.push(stmt);
|
|
242
|
+
yield stmt;
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
// We can only cache statements if the sql text corresponds to a single statement, otherwise it's not clear what
|
|
246
|
+
// portion of the original sql text to use as a key.
|
|
247
|
+
if (preparedStatements.length === 1 && this.statementCache) {
|
|
248
|
+
const stmt = preparedStatements[0];
|
|
249
|
+
// Don't cache EXPLAIN statements, their result becomes invalid after schema changes.
|
|
250
|
+
if (this.sqlite3_stmt_isexplain(stmt) == 0) {
|
|
251
|
+
const evicted = this.statementCache.addStatement(sql, stmt);
|
|
252
|
+
if (evicted != null) {
|
|
253
|
+
await api.finalize(evicted);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// We're not caching statements, so finalize them.
|
|
261
|
+
for (const stmt of preparedStatements) {
|
|
262
|
+
await api.finalize(stmt);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
201
266
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class PreparedStatementCache {
|
|
2
|
+
readonly #size: number;
|
|
3
|
+
// Note that Map preserves insertion order, which allows using it as an LRU
|
|
4
|
+
// cache (with the first element being the first element to evict).
|
|
5
|
+
readonly #statements = new Map<string, number>();
|
|
6
|
+
|
|
7
|
+
constructor(size: number) {
|
|
8
|
+
this.#size = size;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Attempts to look up the cached sql statement, if it's currently cached.
|
|
13
|
+
*/
|
|
14
|
+
lookup(sql: string): number | null {
|
|
15
|
+
const foundStatement = this.#statements.get(sql);
|
|
16
|
+
if (foundStatement != null) {
|
|
17
|
+
// Delete and re-insert to move to the end (most-recently-used position).
|
|
18
|
+
this.#statements.delete(sql);
|
|
19
|
+
this.#statements.set(sql, foundStatement);
|
|
20
|
+
return foundStatement;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adds a new statement into the cache.
|
|
28
|
+
*
|
|
29
|
+
* If that exceeds the target size of the statement cache, returns an old statement to evict.
|
|
30
|
+
* The caller is responsible for freeing that statement.
|
|
31
|
+
*/
|
|
32
|
+
addStatement(sql: string, statement: number): number | null {
|
|
33
|
+
this.#statements.set(sql, statement);
|
|
34
|
+
|
|
35
|
+
if (this.#statements.size > this.#size) {
|
|
36
|
+
for (const [k, v] of this.#statements.entries()) {
|
|
37
|
+
this.#statements.delete(k);
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
drain(): number[] {
|
|
46
|
+
const values = [...this.#statements.values()];
|
|
47
|
+
this.#statements.clear();
|
|
48
|
+
return values;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -67,8 +67,16 @@ export class WASQLiteOpenFactory implements SQLOpenFactory {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
async openConnection(): Promise<PoolConnection> {
|
|
70
|
-
const {
|
|
71
|
-
|
|
70
|
+
const {
|
|
71
|
+
enableMultiTabs,
|
|
72
|
+
useWebWorker,
|
|
73
|
+
vfs,
|
|
74
|
+
dbFilename,
|
|
75
|
+
encryptionKey,
|
|
76
|
+
temporaryStorage,
|
|
77
|
+
cacheSizeKb,
|
|
78
|
+
preparedStatementsCache
|
|
79
|
+
} = this.options;
|
|
72
80
|
|
|
73
81
|
if (!enableMultiTabs) {
|
|
74
82
|
this.logger.log({ level: LogLevels.warn, message: 'Multiple tabs are not enabled in this browser' });
|
|
@@ -85,7 +93,9 @@ export class WASQLiteOpenFactory implements SQLOpenFactory {
|
|
|
85
93
|
vfs,
|
|
86
94
|
encryptionKey,
|
|
87
95
|
temporaryStorage,
|
|
88
|
-
cacheSizeKb
|
|
96
|
+
cacheSizeKb,
|
|
97
|
+
// TODO: Enable prepared statement cache by default?
|
|
98
|
+
preparedStatementsCache: preparedStatementsCache ?? 0
|
|
89
99
|
};
|
|
90
100
|
}
|
|
91
101
|
|
|
@@ -140,10 +150,12 @@ export class WASQLiteOpenFactory implements SQLOpenFactory {
|
|
|
140
150
|
// This VFS supports concurrent reads, so we can open additional workers to host read-only connections for
|
|
141
151
|
// concurrent reads / writes.
|
|
142
152
|
const additionalReadersCount = this.options.additionalReaders ?? 1;
|
|
153
|
+
|
|
154
|
+
const additionalReaderPromises: Promise<DatabaseClient>[] = [];
|
|
143
155
|
for (let i = 0; i < additionalReadersCount; i++) {
|
|
144
|
-
|
|
145
|
-
additionalReaders.push(reader);
|
|
156
|
+
additionalReaderPromises.push(openDatabaseWorker(true));
|
|
146
157
|
}
|
|
158
|
+
additionalReaders.push(...(await Promise.all(additionalReaderPromises)));
|
|
147
159
|
}
|
|
148
160
|
} else {
|
|
149
161
|
// Don't use a web worker. Instead, open the MultiDatabaseServer a worker would use locally.
|
|
@@ -5,6 +5,7 @@ import { getNavigatorLocks } from '../../shared/navigator.js';
|
|
|
5
5
|
import { RawSqliteConnection, RawWaSqliteDatabaseOptions } from '../../db/adapters/wa-sqlite/RawSqliteConnection.js';
|
|
6
6
|
import { ConcurrentSqliteConnection } from '../../db/adapters/wa-sqlite/ConcurrentConnection.js';
|
|
7
7
|
import { WASQLiteVFS } from '../../db/adapters/wa-sqlite/vfs.js';
|
|
8
|
+
import { Mutex } from '@powersync/shared-internals';
|
|
8
9
|
|
|
9
10
|
const OPEN_DB_LOCK = 'open-wasqlite-db';
|
|
10
11
|
|
|
@@ -18,7 +19,8 @@ export interface ConnectToMultiDatabaseServerOptions {
|
|
|
18
19
|
* Shared state to manage multiple database connections hosted by a worker.
|
|
19
20
|
*/
|
|
20
21
|
export class MultiDatabaseServer {
|
|
21
|
-
|
|
22
|
+
readonly #activeDatabases = new Map<string, DatabaseServer>();
|
|
23
|
+
readonly #localOpenLock = new Mutex();
|
|
22
24
|
|
|
23
25
|
constructor(readonly logger: PowerSyncLogger) {}
|
|
24
26
|
|
|
@@ -38,7 +40,7 @@ export class MultiDatabaseServer {
|
|
|
38
40
|
|
|
39
41
|
async connectToExisting(name: string, lockName: string): Promise<ClientConnectionView> {
|
|
40
42
|
return getNavigatorLocks().request(OPEN_DB_LOCK, async () => {
|
|
41
|
-
const server = this
|
|
43
|
+
const server = this.#activeDatabases.get(name);
|
|
42
44
|
if (server == null) {
|
|
43
45
|
throw new Error(`connectToExisting(${name}) failed because the worker doesn't own a database with that name.`);
|
|
44
46
|
}
|
|
@@ -55,7 +57,7 @@ export class MultiDatabaseServer {
|
|
|
55
57
|
|
|
56
58
|
for (let count = 0; count < maxAttempts - 1; count++) {
|
|
57
59
|
try {
|
|
58
|
-
server = await this
|
|
60
|
+
server = await this.#databaseOpenAttempt(logger, options);
|
|
59
61
|
} catch (error) {
|
|
60
62
|
this.logger.log({
|
|
61
63
|
level: LogLevels.warn,
|
|
@@ -67,24 +69,22 @@ export class MultiDatabaseServer {
|
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
// Final attempt if we haven't been able to open the server - rethrow errors if we still can't open.
|
|
70
|
-
server ??= await this
|
|
72
|
+
server ??= await this.#databaseOpenAttempt(logger, options);
|
|
71
73
|
return server.connect(lockName);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
async #databaseOpenAttempt(logger: PowerSyncLogger, options: RawWaSqliteDatabaseOptions): Promise<DatabaseServer> {
|
|
77
|
+
const { filename, readonly, vfs } = options;
|
|
78
|
+
// We don't need navigator locks for shared workers because all queries run in this shared worker exclusively.
|
|
79
|
+
// For read-only connections, we use a VFS that supports concurrent reads (so a single lock on the connection is
|
|
80
|
+
// fine). In-memory databases either run in a shared worker or aren't shared across tabs at all, so the internal
|
|
81
|
+
// lock is enough.
|
|
82
|
+
const needsNavigatorLocks = !(isSharedWorker || readonly || vfs == WASQLiteVFS.InMemoryVfs);
|
|
83
|
+
const activeDatabases = this.#activeDatabases;
|
|
84
|
+
|
|
85
|
+
async function openDatabase() {
|
|
86
|
+
let server: DatabaseServer | undefined = activeDatabases.get(filename);
|
|
82
87
|
if (server == null) {
|
|
83
|
-
// We don't need navigator locks for shared workers because all queries run in this shared worker exclusively.
|
|
84
|
-
// For read-only connections, we use a VFS that supports concurrent reads (so a single lock on the connection is
|
|
85
|
-
// fine). In-memory databases either run in a shared worker or aren't shared across tabs at all, so the internal
|
|
86
|
-
// lock is enough.
|
|
87
|
-
const needsNavigatorLocks = !(isSharedWorker || readonly || vfs == WASQLiteVFS.InMemoryVfs);
|
|
88
88
|
const connection = new RawSqliteConnection(options);
|
|
89
89
|
const withSafeConcurrency = new ConcurrentSqliteConnection(connection, needsNavigatorLocks);
|
|
90
90
|
|
|
@@ -101,21 +101,29 @@ export class MultiDatabaseServer {
|
|
|
101
101
|
}
|
|
102
102
|
returnLease();
|
|
103
103
|
|
|
104
|
-
const onClose = () =>
|
|
104
|
+
const onClose = () => activeDatabases.delete(filename);
|
|
105
105
|
server = new DatabaseServer({
|
|
106
106
|
inner: withSafeConcurrency,
|
|
107
107
|
logger,
|
|
108
108
|
onClose
|
|
109
109
|
});
|
|
110
|
-
|
|
110
|
+
activeDatabases.set(filename, server);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
return server;
|
|
114
|
-
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (needsNavigatorLocks) {
|
|
117
|
+
return getNavigatorLocks().request(OPEN_DB_LOCK, openDatabase);
|
|
118
|
+
} else {
|
|
119
|
+
// Even if we don't need navigator locks, this avoids a race between the activeDatabases.get() call, the async
|
|
120
|
+
// open logic and the final activeDatabases.set() step.
|
|
121
|
+
return this.#localOpenLock.runExclusive(openDatabase);
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
closeAll() {
|
|
118
|
-
const existingDatabases = [...this
|
|
126
|
+
const existingDatabases = [...this.#activeDatabases.values()];
|
|
119
127
|
return Promise.all(
|
|
120
128
|
existingDatabases.map((db) => {
|
|
121
129
|
db.forceClose();
|