@framers/sql-storage-adapter 0.6.15 → 0.7.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/README.md +174 -3
- package/dist/adapters/baseStorageAdapter.d.ts.map +1 -1
- package/dist/adapters/baseStorageAdapter.js +4 -2
- package/dist/adapters/baseStorageAdapter.js.map +1 -1
- package/dist/adapters/betterSqliteAdapter.d.ts +18 -2
- package/dist/adapters/betterSqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/betterSqliteAdapter.js +23 -7
- package/dist/adapters/betterSqliteAdapter.js.map +1 -1
- package/dist/adapters/capacitorSqliteAdapter.d.ts +8 -0
- package/dist/adapters/capacitorSqliteAdapter.d.ts.map +1 -1
- package/dist/adapters/capacitorSqliteAdapter.js +11 -1
- package/dist/adapters/capacitorSqliteAdapter.js.map +1 -1
- package/dist/adapters/electron/electronMainAdapter.d.ts +10 -0
- package/dist/adapters/electron/electronMainAdapter.d.ts.map +1 -1
- package/dist/adapters/electron/electronMainAdapter.js +21 -12
- package/dist/adapters/electron/electronMainAdapter.js.map +1 -1
- package/dist/adapters/indexedDbAdapter.d.ts +202 -54
- package/dist/adapters/indexedDbAdapter.d.ts.map +1 -1
- package/dist/adapters/indexedDbAdapter.js +560 -123
- package/dist/adapters/indexedDbAdapter.js.map +1 -1
- package/dist/adapters/postgresAdapter.d.ts +159 -3
- package/dist/adapters/postgresAdapter.d.ts.map +1 -1
- package/dist/adapters/postgresAdapter.js +667 -140
- package/dist/adapters/postgresAdapter.js.map +1 -1
- package/dist/adapters/sqlJsAdapter.d.ts +21 -3
- package/dist/adapters/sqlJsAdapter.d.ts.map +1 -1
- package/dist/adapters/sqlJsAdapter.js +29 -26
- package/dist/adapters/sqlJsAdapter.js.map +1 -1
- package/dist/core/contracts/index.d.ts +9 -0
- package/dist/core/contracts/index.d.ts.map +1 -1
- package/dist/core/contracts/index.js.map +1 -1
- package/dist/core/database.d.ts +43 -8
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +40 -5
- package/dist/core/database.js.map +1 -1
- package/dist/core/resolver.d.ts +20 -1
- package/dist/core/resolver.d.ts.map +1 -1
- package/dist/core/resolver.js +33 -11
- package/dist/core/resolver.js.map +1 -1
- package/dist/features/kv/idb.d.ts +79 -0
- package/dist/features/kv/idb.d.ts.map +1 -0
- package/dist/features/kv/idb.js +14 -0
- package/dist/features/kv/idb.js.map +1 -0
- package/dist/features/kv/index.d.ts +14 -0
- package/dist/features/kv/index.d.ts.map +1 -0
- package/dist/features/kv/index.js +14 -0
- package/dist/features/kv/index.js.map +1 -0
- package/dist/features/kv/indexedDbKeyValueStore.d.ts +77 -0
- package/dist/features/kv/indexedDbKeyValueStore.d.ts.map +1 -0
- package/dist/features/kv/indexedDbKeyValueStore.js +127 -0
- package/dist/features/kv/indexedDbKeyValueStore.js.map +1 -0
- package/dist/features/kv/keyValueStore.d.ts +48 -0
- package/dist/features/kv/keyValueStore.d.ts.map +1 -0
- package/dist/features/kv/keyValueStore.js +54 -0
- package/dist/features/kv/keyValueStore.js.map +1 -0
- package/dist/features/kv/sqlKeyValueStore.d.ts +34 -0
- package/dist/features/kv/sqlKeyValueStore.d.ts.map +1 -0
- package/dist/features/kv/sqlKeyValueStore.js +91 -0
- package/dist/features/kv/sqlKeyValueStore.js.map +1 -0
- package/dist/features/kv/valueCodec.d.ts +35 -0
- package/dist/features/kv/valueCodec.d.ts.map +1 -0
- package/dist/features/kv/valueCodec.js +462 -0
- package/dist/features/kv/valueCodec.js.map +1 -0
- package/dist/features/migrations/dataExport.js +9 -6
- package/dist/features/migrations/dataExport.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/shared/errors.d.ts +23 -0
- package/dist/shared/errors.d.ts.map +1 -0
- package/dist/shared/errors.js +44 -0
- package/dist/shared/errors.js.map +1 -0
- package/dist/shared/logger.d.ts +26 -0
- package/dist/shared/logger.d.ts.map +1 -0
- package/dist/shared/logger.js +26 -0
- package/dist/shared/logger.js.map +1 -0
- package/dist/shared/parameterUtils.d.ts +7 -0
- package/dist/shared/parameterUtils.d.ts.map +1 -1
- package/dist/shared/parameterUtils.js +25 -0
- package/dist/shared/parameterUtils.js.map +1 -1
- package/dist/shared/postgresTarget.d.ts +4 -3
- package/dist/shared/postgresTarget.d.ts.map +1 -1
- package/dist/shared/postgresTarget.js +6 -4
- package/dist/shared/postgresTarget.js.map +1 -1
- package/dist/shared/segment.d.ts +43 -0
- package/dist/shared/segment.d.ts.map +1 -0
- package/dist/shared/segment.js +77 -0
- package/dist/shared/segment.js.map +1 -0
- package/dist/shared/sqlPlaceholders.d.ts +40 -0
- package/dist/shared/sqlPlaceholders.d.ts.map +1 -0
- package/dist/shared/sqlPlaceholders.js +276 -0
- package/dist/shared/sqlPlaceholders.js.map +1 -0
- package/package.json +10 -2
|
@@ -26,12 +26,12 @@
|
|
|
26
26
|
*
|
|
27
27
|
* @example
|
|
28
28
|
* ```typescript
|
|
29
|
-
* import { IndexedDbAdapter } from '@framers/sql-storage-adapter
|
|
29
|
+
* import { IndexedDbAdapter } from '@framers/sql-storage-adapter';
|
|
30
30
|
*
|
|
31
31
|
* const adapter = new IndexedDbAdapter({
|
|
32
32
|
* dbName: 'my-app-db',
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* durability: 'commit', // each write is in IndexedDB before it resolves
|
|
34
|
+
* crossTab: true, // two tabs of the site keep each other's writes
|
|
35
35
|
* });
|
|
36
36
|
*
|
|
37
37
|
* await adapter.open();
|
|
@@ -43,11 +43,45 @@
|
|
|
43
43
|
* ```
|
|
44
44
|
*/
|
|
45
45
|
import initSqlJs from 'sql.js';
|
|
46
|
-
import { normaliseParameters } from '../shared/parameterUtils.js';
|
|
46
|
+
import { expandNamedParameters, normaliseParameters } from '../shared/parameterUtils.js';
|
|
47
|
+
import { toSqliteStatement } from '../shared/sqlPlaceholders.js';
|
|
47
48
|
import { createSerializedTransactionRunner, createSharedConnectionTransactionAdapter, } from '../shared/serializedTransaction.js';
|
|
48
49
|
import { createConnectionGate, whenAdmitted } from '../shared/connectionGate.js';
|
|
49
|
-
|
|
50
|
+
import { segmentName } from '../shared/segment.js';
|
|
51
|
+
/** How long a statement waits for an open transaction or the cross-tab lock before the adapter warns. */
|
|
50
52
|
const LONG_WAIT_MS = 10000;
|
|
53
|
+
/** The object store's key of the database image. */
|
|
54
|
+
const IMAGE_KEY = 'db';
|
|
55
|
+
/**
|
|
56
|
+
* The object store's key of the image's version stamp: every save writes a
|
|
57
|
+
* new one beside the image, in the same IndexedDB transaction, so a tab can
|
|
58
|
+
* tell that another has saved since it loaded.
|
|
59
|
+
*/
|
|
60
|
+
const STAMP_KEY = 'db:stamp';
|
|
61
|
+
/** How often a statement runs again after another tab saved first, without Web Locks. */
|
|
62
|
+
const MAX_CONFLICT_RETRIES = 50;
|
|
63
|
+
/**
|
|
64
|
+
* Connection settings sql.js's export drops: `export()` closes the
|
|
65
|
+
* connection and opens it again, which resets every per-connection PRAGMA. A
|
|
66
|
+
* save reads these before it exports and sets again the ones a caller changed.
|
|
67
|
+
*/
|
|
68
|
+
const CONNECTION_PRAGMAS = [
|
|
69
|
+
'foreign_keys',
|
|
70
|
+
'recursive_triggers',
|
|
71
|
+
'automatic_index',
|
|
72
|
+
'cache_size',
|
|
73
|
+
'cache_spill',
|
|
74
|
+
'cell_size_check',
|
|
75
|
+
'ignore_check_constraints',
|
|
76
|
+
'legacy_alter_table',
|
|
77
|
+
'query_only',
|
|
78
|
+
'reverse_unordered_selects',
|
|
79
|
+
'secure_delete',
|
|
80
|
+
'synchronous',
|
|
81
|
+
'temp_store',
|
|
82
|
+
'trusted_schema',
|
|
83
|
+
'busy_timeout',
|
|
84
|
+
];
|
|
51
85
|
// ============================================================================
|
|
52
86
|
// GLOBAL SQL.JS SINGLETON
|
|
53
87
|
// ============================================================================
|
|
@@ -73,7 +107,23 @@ async function getGlobalSQL(config) {
|
|
|
73
107
|
});
|
|
74
108
|
return globalSQLPromise;
|
|
75
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* A `transaction()` another tab saved over: refused, with nothing of it
|
|
112
|
+
* stored. The adapter has loaded the other tab's image, so running the
|
|
113
|
+
* transaction again keeps both tabs' writes.
|
|
114
|
+
*/
|
|
115
|
+
export class IndexedDbConflictError extends Error {
|
|
116
|
+
constructor(message) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.name = 'IndexedDbConflictError';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
76
121
|
const DB_VERSION = 1;
|
|
122
|
+
/** The first keyword of a statement, past its leading comments and whitespace. */
|
|
123
|
+
const firstKeyword = (statement) => {
|
|
124
|
+
const rest = statement.replace(/^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/, '');
|
|
125
|
+
return (/^[A-Za-z]+/.exec(rest)?.[0] ?? '').toUpperCase();
|
|
126
|
+
};
|
|
77
127
|
/**
|
|
78
128
|
* Storage adapter using IndexedDB + sql.js for client-side SQL persistence.
|
|
79
129
|
*
|
|
@@ -91,51 +141,21 @@ const DB_VERSION = 1;
|
|
|
91
141
|
* - Auto-save batching reduces overhead
|
|
92
142
|
* - Good for: Production PWAs, offline-first apps, persistent client-side storage
|
|
93
143
|
*
|
|
94
|
-
* **Is This Necessary?**
|
|
95
|
-
*
|
|
96
|
-
* **✅ YES** if you need:
|
|
97
|
-
* - Data to survive page refreshes (production apps)
|
|
98
|
-
* - Offline-first functionality (PWAs)
|
|
99
|
-
* - Privacy-first apps (data never leaves browser)
|
|
100
|
-
* - Zero manual save logic (just works)
|
|
101
|
-
*
|
|
102
|
-
* **❌ NO** if you:
|
|
103
|
-
* - Only need temporary/in-memory data (edge functions)
|
|
104
|
-
* - Are prototyping and don't care about persistence
|
|
105
|
-
* - Want to manually control when data is saved
|
|
106
|
-
* - Don't need data to survive refreshes
|
|
107
|
-
*
|
|
108
|
-
* **Alternative:** You could use sql.js directly and manually save to IndexedDB yourself, but you'd lose:
|
|
109
|
-
* - Automatic batched saves (performance optimization)
|
|
110
|
-
* - Reliable persistence (easy to forget manual saves = data loss)
|
|
111
|
-
* - Consistent API across platforms
|
|
112
|
-
* - Production-ready defaults
|
|
113
|
-
*
|
|
114
|
-
* **Bottom line:** This adapter is **necessary for production web apps** that need persistence. For prototypes or edge functions, sql.js alone is fine.
|
|
115
|
-
*
|
|
116
144
|
* **How It Works:**
|
|
117
145
|
* 1. IndexedDB stores the SQLite database file (binary blob) - this is just storage
|
|
118
146
|
* 2. sql.js (WASM) loads the database into memory and executes SQL - this provides SQL capabilities
|
|
119
|
-
* 3. After writes, the updated database is
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* - IndexedDB alone: NoSQL key-value store, no SQL support
|
|
123
|
-
* - sql.js alone: In-memory SQL, but **no automatic persistence** (you must manually save)
|
|
124
|
-
* - Combined: Full SQL + **automatic browser-native persistence** = production-ready solution
|
|
147
|
+
* 3. After writes, the updated database is saved back to IndexedDB: on a timer
|
|
148
|
+
* (the default), before each write resolves (`durability: 'commit'`), or
|
|
149
|
+
* under a cross-tab lock (`crossTab`)
|
|
125
150
|
*
|
|
126
151
|
* **Capabilities:**
|
|
127
152
|
* - ✅ Transactions (via sql.js)
|
|
128
153
|
* - ✅ Persistence (via IndexedDB - automatic!)
|
|
129
154
|
* - ✅ Full SQL support (via sql.js)
|
|
130
155
|
* - ✅ Export/import
|
|
131
|
-
* -
|
|
156
|
+
* - ✅ Several tabs on one database, with `crossTab`
|
|
132
157
|
* - ❌ Server-side (browser only)
|
|
133
158
|
*
|
|
134
|
-
* **Performance:**
|
|
135
|
-
* - Fast reads (in-memory SQL)
|
|
136
|
-
* - Moderate writes (IndexedDB persistence)
|
|
137
|
-
* - Auto-save batching reduces IDB overhead
|
|
138
|
-
*
|
|
139
159
|
* @example Client-side application
|
|
140
160
|
* ```typescript
|
|
141
161
|
* const adapter = new IndexedDbAdapter({ dbName: 'my-app-db' });
|
|
@@ -144,6 +164,7 @@ const DB_VERSION = 1;
|
|
|
144
164
|
* ```
|
|
145
165
|
*/
|
|
146
166
|
export class IndexedDbAdapter {
|
|
167
|
+
/** @throws {Error} When `options.segment` is not a name a segment can take. */
|
|
147
168
|
constructor(options = {}) {
|
|
148
169
|
this.kind = 'indexeddb';
|
|
149
170
|
this.capabilities = new Set(['transactions', 'persistence', 'json', 'prepared']);
|
|
@@ -157,13 +178,31 @@ export class IndexedDbAdapter {
|
|
|
157
178
|
this.savedGeneration = 0;
|
|
158
179
|
/** The save in progress, if any. */
|
|
159
180
|
this.saving = null;
|
|
181
|
+
/** The generation the save in progress exported. */
|
|
182
|
+
this.savingGeneration = 0;
|
|
160
183
|
/** Whether a failed auto-save has been reported since the last one that succeeded. */
|
|
161
184
|
this.saveFailureReported = false;
|
|
185
|
+
/** The open() in progress, which a second call waits for. */
|
|
186
|
+
this.opening = null;
|
|
162
187
|
/** Set while close() runs: no statement or transaction starts. */
|
|
163
188
|
this.closing = false;
|
|
164
189
|
/** The close() in progress, which a second call waits for. */
|
|
165
190
|
this.closeRun = null;
|
|
166
191
|
this.longWaitReported = false;
|
|
192
|
+
this.lockWaitReported = false;
|
|
193
|
+
/** The stamp of the image the database in memory was loaded from or last saved as. */
|
|
194
|
+
this.loadedStamp = null;
|
|
195
|
+
/** How many stamps this adapter has made, which keeps two of its stamps apart. */
|
|
196
|
+
this.stampCount = 0;
|
|
197
|
+
/** The connection settings a fresh connection has, which a save need not set again. */
|
|
198
|
+
this.pragmaDefaults = new Map();
|
|
199
|
+
/**
|
|
200
|
+
* With `crossTab`, the lock manager, or null for the version stamp alone;
|
|
201
|
+
* undefined without `crossTab`.
|
|
202
|
+
*/
|
|
203
|
+
this.locks = undefined;
|
|
204
|
+
/** Without Web Locks, this adapter's coordinated operations run one at a time. */
|
|
205
|
+
this.runCoordinatedInTurn = createSerializedTransactionRunner();
|
|
167
206
|
/**
|
|
168
207
|
* The connection is shared, so transaction() runs one callback at a time.
|
|
169
208
|
*/
|
|
@@ -180,11 +219,18 @@ export class IndexedDbAdapter {
|
|
|
180
219
|
afterMs: LONG_WAIT_MS,
|
|
181
220
|
onLongWait: () => this.reportLongWait(),
|
|
182
221
|
};
|
|
183
|
-
this.dbName = options.dbName || 'app-db';
|
|
222
|
+
this.dbName = segmentName(options.dbName || 'app-db', options.segment);
|
|
184
223
|
this.storeName = options.storeName || 'sqliteDb';
|
|
185
224
|
this.autoSave = options.autoSave ?? true;
|
|
186
225
|
this.saveIntervalMs = options.saveIntervalMs || 5000;
|
|
187
226
|
this.sqlJsConfig = options.sqlJsConfig || {};
|
|
227
|
+
this.crossTab =
|
|
228
|
+
options.crossTab === true ? {} : typeof options.crossTab === 'object' && options.crossTab !== null ? options.crossTab : null;
|
|
229
|
+
this.durability = this.crossTab ? 'commit' : options.durability ?? 'interval';
|
|
230
|
+
}
|
|
231
|
+
/** The name of the Web Lock that coordinates the tabs on this database. */
|
|
232
|
+
get lockName() {
|
|
233
|
+
return `@framers/sql-storage-adapter:indexeddb:${this.dbName}:${this.storeName}`;
|
|
188
234
|
}
|
|
189
235
|
/**
|
|
190
236
|
* Opens IndexedDB and initializes sql.js database.
|
|
@@ -192,29 +238,57 @@ export class IndexedDbAdapter {
|
|
|
192
238
|
*
|
|
193
239
|
* Uses a global sql.js singleton to prevent race conditions when
|
|
194
240
|
* multiple adapters (e.g., fabric_codex_db and quarry-codex-local)
|
|
195
|
-
* are initialized simultaneously.
|
|
241
|
+
* are initialized simultaneously. Two calls at once open once.
|
|
196
242
|
*/
|
|
197
|
-
|
|
243
|
+
open(options) {
|
|
198
244
|
if (this.db) {
|
|
199
|
-
return; // Already open
|
|
245
|
+
return Promise.resolve(); // Already open
|
|
246
|
+
}
|
|
247
|
+
if (!this.opening) {
|
|
248
|
+
this.foreignKeys = options?.foreignKeys;
|
|
249
|
+
this.opening = this.openNow().finally(() => {
|
|
250
|
+
this.opening = null;
|
|
251
|
+
});
|
|
200
252
|
}
|
|
253
|
+
return this.opening;
|
|
254
|
+
}
|
|
255
|
+
async openNow() {
|
|
201
256
|
// Initialize sql.js using global singleton (prevents race conditions)
|
|
202
257
|
this.SQL = await getGlobalSQL(this.sqlJsConfig);
|
|
203
258
|
// Open IndexedDB with retry logic for race conditions
|
|
204
259
|
this.idb = await this.openIndexedDbWithRetry();
|
|
205
260
|
// Load existing database from IndexedDB or create new
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
261
|
+
const stored = await this.loadFromIndexedDb();
|
|
262
|
+
const db = stored.image ? new this.SQL.Database(stored.image) : new this.SQL.Database();
|
|
263
|
+
// What a fresh connection has, and export() returns to. Read before the
|
|
264
|
+
// settings open() was asked for, so a save carries those like any other a
|
|
265
|
+
// caller made.
|
|
266
|
+
this.pragmaDefaults = readPragmas(db);
|
|
267
|
+
this.db = db;
|
|
268
|
+
this.loadedStamp = stored.stamp;
|
|
269
|
+
this.applyConnectionSettings();
|
|
270
|
+
if (this.crossTab) {
|
|
271
|
+
this.locks = this.resolveLocks(this.crossTab);
|
|
212
272
|
}
|
|
213
|
-
|
|
214
|
-
|
|
273
|
+
else if (this.autoSave) {
|
|
274
|
+
// Start auto-save interval; with crossTab every write is saved under the lock.
|
|
215
275
|
this.startAutoSave();
|
|
216
276
|
}
|
|
217
277
|
}
|
|
278
|
+
/** The lock manager crossTab uses, or null for the version stamp alone. */
|
|
279
|
+
resolveLocks(options) {
|
|
280
|
+
if (options.locks !== undefined) {
|
|
281
|
+
return options.locks;
|
|
282
|
+
}
|
|
283
|
+
const scope = globalThis;
|
|
284
|
+
const locks = scope.navigator?.locks;
|
|
285
|
+
if (locks && typeof locks.request === 'function') {
|
|
286
|
+
return locks;
|
|
287
|
+
}
|
|
288
|
+
console.warn(`[IndexedDbAdapter] Web Locks are not available here, so "${this.dbName}" coordinates tabs by its version stamp alone: ` +
|
|
289
|
+
'a statement another tab saved over runs again, and a transaction() another tab saved over fails with IndexedDbConflictError.');
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
218
292
|
/**
|
|
219
293
|
* Executes a SQL statement that doesn't return rows (INSERT, UPDATE, DELETE, CREATE, etc.).
|
|
220
294
|
* @param statement SQL statement with ? or :name placeholders
|
|
@@ -222,15 +296,19 @@ export class IndexedDbAdapter {
|
|
|
222
296
|
* @returns Result with changes count and last insert row ID
|
|
223
297
|
*/
|
|
224
298
|
async run(statement, parameters) {
|
|
225
|
-
|
|
299
|
+
if (this.crossTab) {
|
|
300
|
+
return this.coordinatedStatement(() => this.runNow(statement, parameters));
|
|
301
|
+
}
|
|
302
|
+
return whenAdmitted(this.gate, () => this.stored(this.runNow(statement, parameters)), undefined, this.longWait);
|
|
226
303
|
}
|
|
227
304
|
runNow(statement, parameters) {
|
|
305
|
+
({ statement, parameters } = toSqliteStatement(statement, parameters));
|
|
228
306
|
this.ensureOpen();
|
|
229
307
|
const stmt = this.db.prepare(statement);
|
|
230
308
|
try {
|
|
231
309
|
const { named, positional } = normaliseParameters(parameters);
|
|
232
310
|
if (named) {
|
|
233
|
-
stmt.bind(named);
|
|
311
|
+
stmt.bind(expandNamedParameters(named));
|
|
234
312
|
}
|
|
235
313
|
else if (positional) {
|
|
236
314
|
stmt.bind(positional);
|
|
@@ -260,21 +338,37 @@ export class IndexedDbAdapter {
|
|
|
260
338
|
return rows.length > 0 ? rows[0] : null;
|
|
261
339
|
}
|
|
262
340
|
/**
|
|
263
|
-
* Executes a SQL query and returns all matching rows.
|
|
341
|
+
* Executes a SQL query and returns all matching rows. A statement that
|
|
342
|
+
* writes (`INSERT ... RETURNING`) is saved like a `run()`.
|
|
264
343
|
* @param statement SELECT statement
|
|
265
344
|
* @param parameters Query parameters
|
|
266
345
|
* @returns Array of result rows as objects
|
|
267
346
|
*/
|
|
268
347
|
async all(statement, parameters) {
|
|
269
|
-
|
|
348
|
+
if (this.crossTab) {
|
|
349
|
+
return this.coordinatedStatement(() => this.allNow(statement, parameters));
|
|
350
|
+
}
|
|
351
|
+
return whenAdmitted(this.gate, () => {
|
|
352
|
+
const before = this.writeGeneration;
|
|
353
|
+
const rows = this.allNow(statement, parameters);
|
|
354
|
+
return this.writeGeneration === before ? rows : this.stored(rows);
|
|
355
|
+
}, undefined, this.longWait);
|
|
270
356
|
}
|
|
271
357
|
allNow(statement, parameters) {
|
|
358
|
+
({ statement, parameters } = toSqliteStatement(statement, parameters));
|
|
272
359
|
this.ensureOpen();
|
|
360
|
+
// A statement that is not a plain read can write (INSERT ... RETURNING,
|
|
361
|
+
// WITH ... DELETE): it marks the database unsaved when it changed rows,
|
|
362
|
+
// and a statement that can only write marks it whatever it changed.
|
|
363
|
+
const keyword = firstKeyword(statement);
|
|
364
|
+
const reads = keyword === 'SELECT' || keyword === 'EXPLAIN' || keyword === 'VALUES';
|
|
365
|
+
const mayWrite = keyword === 'WITH' || keyword === 'PRAGMA';
|
|
366
|
+
const changesBefore = mayWrite ? this.totalChanges() : 0;
|
|
273
367
|
const stmt = this.db.prepare(statement);
|
|
274
368
|
try {
|
|
275
369
|
const { named, positional } = normaliseParameters(parameters);
|
|
276
370
|
if (named) {
|
|
277
|
-
stmt.bind(named);
|
|
371
|
+
stmt.bind(expandNamedParameters(named));
|
|
278
372
|
}
|
|
279
373
|
else if (positional) {
|
|
280
374
|
stmt.bind(positional);
|
|
@@ -290,17 +384,27 @@ export class IndexedDbAdapter {
|
|
|
290
384
|
});
|
|
291
385
|
results.push(obj);
|
|
292
386
|
}
|
|
387
|
+
if (!reads && (!mayWrite || this.totalChanges() !== changesBefore)) {
|
|
388
|
+
this.writeGeneration += 1;
|
|
389
|
+
}
|
|
293
390
|
return results;
|
|
294
391
|
}
|
|
295
392
|
finally {
|
|
296
393
|
stmt.free();
|
|
297
394
|
}
|
|
298
395
|
}
|
|
396
|
+
/** The rows changed on this connection since it opened. */
|
|
397
|
+
totalChanges() {
|
|
398
|
+
return Number(this.db.exec('SELECT total_changes()')[0]?.values?.[0]?.[0] ?? 0);
|
|
399
|
+
}
|
|
299
400
|
/**
|
|
300
401
|
* Executes a script containing multiple SQL statements.
|
|
301
402
|
*/
|
|
302
403
|
async exec(script) {
|
|
303
|
-
|
|
404
|
+
if (this.crossTab) {
|
|
405
|
+
return this.coordinatedStatement(() => this.execNow(script));
|
|
406
|
+
}
|
|
407
|
+
return whenAdmitted(this.gate, () => this.stored(this.execNow(script)), undefined, this.longWait);
|
|
304
408
|
}
|
|
305
409
|
execNow(script) {
|
|
306
410
|
this.ensureOpen();
|
|
@@ -312,6 +416,16 @@ export class IndexedDbAdapter {
|
|
|
312
416
|
this.writeGeneration += 1;
|
|
313
417
|
}
|
|
314
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* With `durability: 'commit'`, resolves with `result` once the write that
|
|
421
|
+
* produced it is stored; otherwise with `result` at once.
|
|
422
|
+
*/
|
|
423
|
+
stored(result) {
|
|
424
|
+
if (this.durability !== 'commit') {
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
return this.saveThrough(this.writeGeneration).then(() => result);
|
|
428
|
+
}
|
|
315
429
|
/**
|
|
316
430
|
* Executes a callback within a database transaction.
|
|
317
431
|
*
|
|
@@ -319,33 +433,46 @@ export class IndexedDbAdapter {
|
|
|
319
433
|
* connection. The callback's statements go through the handle it receives;
|
|
320
434
|
* a statement issued through the adapter itself while the callback runs
|
|
321
435
|
* waits until the transaction ends, so a callback must not await one. The
|
|
322
|
-
* auto-save tick does not save while a transaction is open.
|
|
436
|
+
* auto-save tick does not save while a transaction is open. With
|
|
437
|
+
* `durability: 'commit'` the transaction resolves once it is stored.
|
|
323
438
|
*/
|
|
324
439
|
async transaction(fn) {
|
|
325
440
|
this.ensureOpen();
|
|
441
|
+
if (this.crossTab) {
|
|
442
|
+
return this.coordinatedTransaction(fn);
|
|
443
|
+
}
|
|
326
444
|
const queued = setTimeout(() => this.reportLongWait(), LONG_WAIT_MS);
|
|
327
|
-
return this.runSerializedTransaction(() => {
|
|
445
|
+
return this.runSerializedTransaction(async () => {
|
|
328
446
|
clearTimeout(queued);
|
|
329
|
-
|
|
447
|
+
const result = await this.transactionNow(fn);
|
|
448
|
+
if (this.durability === 'commit') {
|
|
449
|
+
// The gate is open again, and the next transaction waits for this save.
|
|
450
|
+
await this.saveThrough(this.writeGeneration);
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** Runs one transaction: BEGIN, the callback through its handle, COMMIT or ROLLBACK. */
|
|
456
|
+
transactionNow(fn) {
|
|
457
|
+
return this.gate.hold(async (token) => {
|
|
458
|
+
this.ensureOpen();
|
|
459
|
+
// Outside the try: when BEGIN fails, the transaction that is open
|
|
460
|
+
// (a caller's own BEGIN) is not this one to roll back.
|
|
461
|
+
this.db.run('BEGIN TRANSACTION');
|
|
462
|
+
const transactionAdapter = createSharedConnectionTransactionAdapter(this.handleStatements(token));
|
|
463
|
+
try {
|
|
464
|
+
const result = await fn(transactionAdapter);
|
|
330
465
|
this.ensureOpen();
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
this.writeGeneration += 1;
|
|
342
|
-
return result;
|
|
343
|
-
}
|
|
344
|
-
catch (error) {
|
|
345
|
-
this.rollbackQuietly();
|
|
346
|
-
throw error;
|
|
347
|
-
}
|
|
348
|
-
});
|
|
466
|
+
this.db.run('COMMIT');
|
|
467
|
+
// The callback may have written through all() or get() (INSERT ...
|
|
468
|
+
// RETURNING); the commit marks the database unsaved in any case.
|
|
469
|
+
this.writeGeneration += 1;
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
this.rollbackQuietly();
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
349
476
|
});
|
|
350
477
|
}
|
|
351
478
|
/**
|
|
@@ -382,7 +509,8 @@ export class IndexedDbAdapter {
|
|
|
382
509
|
}
|
|
383
510
|
/**
|
|
384
511
|
* Begins a transaction the caller ends with commit() or rollback(). It is not
|
|
385
|
-
* the callback form: other callers' statements are not held out of it.
|
|
512
|
+
* the callback form: other callers' statements are not held out of it. With
|
|
513
|
+
* `crossTab` it is refused: use transaction().
|
|
386
514
|
*/
|
|
387
515
|
async beginTransaction() {
|
|
388
516
|
await this.run('BEGIN');
|
|
@@ -399,6 +527,44 @@ export class IndexedDbAdapter {
|
|
|
399
527
|
async rollback() {
|
|
400
528
|
await this.run('ROLLBACK');
|
|
401
529
|
}
|
|
530
|
+
/**
|
|
531
|
+
* Stores every write made so far in IndexedDB. A `transaction()` that is
|
|
532
|
+
* open is waited for and stored with the rest; a transaction a caller began
|
|
533
|
+
* with `BEGIN` cannot be stored without ending it, so flush() rejects while
|
|
534
|
+
* one is open. Resolves at once when nothing is waiting to be saved.
|
|
535
|
+
*/
|
|
536
|
+
async flush() {
|
|
537
|
+
this.ensureOpen();
|
|
538
|
+
if (this.crossTab) {
|
|
539
|
+
// Every write is stored before it resolves; this stores one whose save
|
|
540
|
+
// failed, unless another tab has saved since.
|
|
541
|
+
await this.coordinatedStatement(() => undefined);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const target = this.writeGeneration;
|
|
545
|
+
while (this.savedGeneration < target && this.db && this.idb) {
|
|
546
|
+
if (this.saving) {
|
|
547
|
+
const covering = this.savingGeneration >= target;
|
|
548
|
+
try {
|
|
549
|
+
await this.saving;
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
if (covering)
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (this.gate.openToken() !== null) {
|
|
558
|
+
await this.gate.ended();
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (this.inTransaction()) {
|
|
562
|
+
throw new Error(`IndexedDbAdapter: flush() cannot store "${this.dbName}" while a transaction begun with BEGIN is open: ` +
|
|
563
|
+
"sql.js's export would end it. COMMIT or ROLLBACK it first.");
|
|
564
|
+
}
|
|
565
|
+
await this.startSave();
|
|
566
|
+
}
|
|
567
|
+
}
|
|
402
568
|
/**
|
|
403
569
|
* Closes the database and saves its committed state to IndexedDB.
|
|
404
570
|
*
|
|
@@ -415,6 +581,9 @@ export class IndexedDbAdapter {
|
|
|
415
581
|
return this.closeRun;
|
|
416
582
|
}
|
|
417
583
|
async closeNow() {
|
|
584
|
+
if (this.opening) {
|
|
585
|
+
await this.opening.catch(() => undefined);
|
|
586
|
+
}
|
|
418
587
|
if (this.saveTimer) {
|
|
419
588
|
clearInterval(this.saveTimer);
|
|
420
589
|
this.saveTimer = null;
|
|
@@ -428,9 +597,20 @@ export class IndexedDbAdapter {
|
|
|
428
597
|
if (this.inTransaction()) {
|
|
429
598
|
this.rollbackQuietly();
|
|
430
599
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
600
|
+
if (this.crossTab) {
|
|
601
|
+
// Under the lock, once the transaction that holds it has failed: a
|
|
602
|
+
// write whose save failed is stored, unless another tab saved since.
|
|
603
|
+
await this.withCoordination(async () => {
|
|
604
|
+
if (this.db && this.hasUnsavedWrites()) {
|
|
605
|
+
await this.saveIfStampIs(this.loadedStamp);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
await this.settleSave();
|
|
611
|
+
if (this.hasUnsavedWrites()) {
|
|
612
|
+
await this.startSave();
|
|
613
|
+
}
|
|
434
614
|
}
|
|
435
615
|
this.db?.close();
|
|
436
616
|
this.db = null;
|
|
@@ -446,7 +626,8 @@ export class IndexedDbAdapter {
|
|
|
446
626
|
}
|
|
447
627
|
/**
|
|
448
628
|
* Exports the database as a Uint8Array (SQLite file format).
|
|
449
|
-
* Can be downloaded or stored externally.
|
|
629
|
+
* Can be downloaded or stored externally. With `crossTab` it is this tab's
|
|
630
|
+
* image, which another tab's save can have overtaken.
|
|
450
631
|
*
|
|
451
632
|
* Throws while a transaction is open: sql.js's export closes and reopens the
|
|
452
633
|
* connection, which would end the transaction.
|
|
@@ -457,29 +638,244 @@ export class IndexedDbAdapter {
|
|
|
457
638
|
if (this.inTransaction()) {
|
|
458
639
|
throw new Error("IndexedDbAdapter: cannot export the database while a transaction is open; sql.js's export would end it.");
|
|
459
640
|
}
|
|
460
|
-
return this.
|
|
641
|
+
return this.exportImage();
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Applies the connection settings open() was asked for. sql.js loses them
|
|
645
|
+
* whenever export() reopens the connection; a save, an export, an import
|
|
646
|
+
* and a reload set them again with the other settings a caller changed
|
|
647
|
+
* (exportImage, replaceDatabase).
|
|
648
|
+
*/
|
|
649
|
+
applyConnectionSettings() {
|
|
650
|
+
if (this.foreignKeys !== undefined) {
|
|
651
|
+
this.db.run(`PRAGMA foreign_keys = ${this.foreignKeys ? 'ON' : 'OFF'};`);
|
|
652
|
+
}
|
|
461
653
|
}
|
|
462
654
|
/**
|
|
463
655
|
* Imports a database from a Uint8Array (SQLite file).
|
|
464
656
|
* Replaces the current database once any open transaction() has ended, and
|
|
465
|
-
* saves it to IndexedDB before it resolves
|
|
657
|
+
* saves it to IndexedDB before it resolves; with `crossTab`, in place of
|
|
658
|
+
* what every tab stored. Bytes that are not a SQLite database are refused,
|
|
659
|
+
* and the adapter keeps its database.
|
|
466
660
|
* @param data SQLite database file
|
|
467
661
|
*/
|
|
468
662
|
async importDatabase(data) {
|
|
469
663
|
this.ensureOpen();
|
|
664
|
+
if (this.crossTab) {
|
|
665
|
+
await this.withCoordination(async () => {
|
|
666
|
+
this.swapIn(data);
|
|
667
|
+
await this.saveIfStampIs(undefined);
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
470
671
|
await whenAdmitted(this.gate, () => {
|
|
471
|
-
this.
|
|
472
|
-
// Opened before the current database closes: an image sql.js refuses
|
|
473
|
-
// leaves the adapter on the database it had.
|
|
474
|
-
const imported = new this.SQL.Database(data);
|
|
475
|
-
this.db.close();
|
|
476
|
-
this.db = imported;
|
|
477
|
-
this.writeGeneration += 1;
|
|
672
|
+
this.swapIn(data);
|
|
478
673
|
// The save exports in this turn, so no transaction can open between
|
|
479
674
|
// the swap and the export and hold the save back.
|
|
480
675
|
return this.startSave();
|
|
481
676
|
}, undefined, this.longWait);
|
|
482
677
|
}
|
|
678
|
+
/** Replaces the database with an image, once sql.js has read it as a SQLite database. */
|
|
679
|
+
swapIn(data) {
|
|
680
|
+
this.ensureOpen();
|
|
681
|
+
const imported = new this.SQL.Database(data);
|
|
682
|
+
try {
|
|
683
|
+
// sqlite3_open reads nothing; the first statement reads the header.
|
|
684
|
+
imported.exec('SELECT count(*) FROM sqlite_master');
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
imported.close();
|
|
688
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
689
|
+
throw new Error(`IndexedDbAdapter: the image to import is not a SQLite database that sql.js can read (${reason}).`);
|
|
690
|
+
}
|
|
691
|
+
this.replaceDatabase(imported);
|
|
692
|
+
this.writeGeneration += 1;
|
|
693
|
+
}
|
|
694
|
+
/** Puts `next` in place of the database, with the connection settings the current one has. */
|
|
695
|
+
replaceDatabase(next) {
|
|
696
|
+
const settings = this.changedPragmas();
|
|
697
|
+
this.db.close();
|
|
698
|
+
this.db = next;
|
|
699
|
+
this.applyPragmas(settings);
|
|
700
|
+
}
|
|
701
|
+
// ==========================================================================
|
|
702
|
+
// Across tabs
|
|
703
|
+
// ==========================================================================
|
|
704
|
+
/**
|
|
705
|
+
* Runs `work` with this tab holding the database: under the Web Lock, or,
|
|
706
|
+
* without Web Locks, after this adapter's earlier coordinated work.
|
|
707
|
+
*/
|
|
708
|
+
withCoordination(work) {
|
|
709
|
+
const waiting = setTimeout(() => this.reportLockWait(), LONG_WAIT_MS);
|
|
710
|
+
const start = () => {
|
|
711
|
+
clearTimeout(waiting);
|
|
712
|
+
return work();
|
|
713
|
+
};
|
|
714
|
+
const locks = this.locks;
|
|
715
|
+
const held = locks ? locks.request(this.lockName, { mode: 'exclusive' }, start) : this.runCoordinatedInTurn(start);
|
|
716
|
+
return held.finally(() => clearTimeout(waiting));
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* One statement across tabs: on the newest stored image, and stored before
|
|
720
|
+
* it resolves. Without Web Locks another tab can save between the load and
|
|
721
|
+
* the save; the statement then runs again on that tab's image.
|
|
722
|
+
*/
|
|
723
|
+
coordinatedStatement(statement) {
|
|
724
|
+
return this.withCoordination(async () => {
|
|
725
|
+
for (let attempt = 1;; attempt += 1) {
|
|
726
|
+
this.ensureOpen();
|
|
727
|
+
await this.syncFromStore();
|
|
728
|
+
const result = statement();
|
|
729
|
+
if (this.inTransaction()) {
|
|
730
|
+
// A BEGIN cannot hold the database across tabs between two calls.
|
|
731
|
+
this.rollbackQuietly();
|
|
732
|
+
throw new Error(`IndexedDbAdapter: "${this.dbName}" coordinates tabs, so a transaction begun with BEGIN through run() or exec() ` +
|
|
733
|
+
'cannot be held open; it was rolled back. Use transaction().');
|
|
734
|
+
}
|
|
735
|
+
if (!this.hasUnsavedWrites() || (await this.saveIfStampIs(this.loadedStamp))) {
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
if (attempt >= MAX_CONFLICT_RETRIES) {
|
|
739
|
+
await this.syncFromStore();
|
|
740
|
+
throw new IndexedDbConflictError(`IndexedDbAdapter: other tabs saved "${this.dbName}" before each of ${attempt} attempts to store a statement; nothing of it is stored.`);
|
|
741
|
+
}
|
|
742
|
+
// Another tab saved first: load its image, and run the statement on it.
|
|
743
|
+
await this.syncFromStore();
|
|
744
|
+
await new Promise((resolve) => setTimeout(resolve, Math.random() * 4 * attempt));
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* One transaction across tabs: on the newest stored image, and stored before
|
|
750
|
+
* it resolves. Without Web Locks another tab can save while the callback
|
|
751
|
+
* runs; the transaction is then refused, nothing of it is stored, and the
|
|
752
|
+
* other tab's image is loaded for the caller to run it again.
|
|
753
|
+
*/
|
|
754
|
+
coordinatedTransaction(fn) {
|
|
755
|
+
return this.withCoordination(async () => {
|
|
756
|
+
this.ensureOpen();
|
|
757
|
+
await this.syncFromStore();
|
|
758
|
+
const result = await this.runSerializedTransaction(() => this.transactionNow(fn));
|
|
759
|
+
if (this.hasUnsavedWrites() && !(await this.saveIfStampIs(this.loadedStamp))) {
|
|
760
|
+
await this.syncFromStore();
|
|
761
|
+
throw new IndexedDbConflictError(`IndexedDbAdapter: another tab saved "${this.dbName}" while this transaction ran, so nothing of it is stored. ` +
|
|
762
|
+
"That tab's writes are loaded; run the transaction again.");
|
|
763
|
+
}
|
|
764
|
+
return result;
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
/** Loads the stored image when another tab (or adapter) has saved since this one loaded or saved. */
|
|
768
|
+
async syncFromStore() {
|
|
769
|
+
const stored = await this.readIfChanged(this.loadedStamp);
|
|
770
|
+
if (!stored || !this.db) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
// The image in memory gives way: what it held that was not stored was
|
|
774
|
+
// refused to its callers when its save failed.
|
|
775
|
+
this.replaceDatabase(stored.image ? new this.SQL.Database(stored.image) : new this.SQL.Database());
|
|
776
|
+
this.loadedStamp = stored.stamp;
|
|
777
|
+
this.savedGeneration = this.writeGeneration;
|
|
778
|
+
}
|
|
779
|
+
/** Reads the stored stamp, and the image with it when the stamp is not `known`; null when it is. */
|
|
780
|
+
readIfChanged(known) {
|
|
781
|
+
return this.withTransactionRetry(() => new Promise((resolve, reject) => {
|
|
782
|
+
const tx = this.idb.transaction(this.storeName, 'readonly');
|
|
783
|
+
const store = tx.objectStore(this.storeName);
|
|
784
|
+
let answer = null;
|
|
785
|
+
const stampRequest = store.get(STAMP_KEY);
|
|
786
|
+
stampRequest.onsuccess = () => {
|
|
787
|
+
const stamp = typeof stampRequest.result === 'string' ? stampRequest.result : null;
|
|
788
|
+
if (stamp === known)
|
|
789
|
+
return;
|
|
790
|
+
const imageRequest = store.get(IMAGE_KEY);
|
|
791
|
+
imageRequest.onsuccess = () => {
|
|
792
|
+
answer = { image: imageRequest.result ?? null, stamp };
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
tx.oncomplete = () => resolve(answer);
|
|
796
|
+
tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: reading "${this.dbName}" was aborted.`));
|
|
797
|
+
}));
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Stores the image when the stored stamp is still `expected`, in one
|
|
801
|
+
* IndexedDB transaction, so no other tab's save lands between the check and
|
|
802
|
+
* the write. `undefined` stores it whatever the stamp is. Resolves with
|
|
803
|
+
* whether it stored the image.
|
|
804
|
+
*/
|
|
805
|
+
async saveIfStampIs(expected) {
|
|
806
|
+
const generation = this.writeGeneration;
|
|
807
|
+
const image = this.exportImage();
|
|
808
|
+
const stamp = this.nextStamp();
|
|
809
|
+
const stored = await this.withTransactionRetry(() => new Promise((resolve, reject) => {
|
|
810
|
+
const tx = this.idb.transaction(this.storeName, 'readwrite');
|
|
811
|
+
const store = tx.objectStore(this.storeName);
|
|
812
|
+
let wrote = false;
|
|
813
|
+
const write = () => {
|
|
814
|
+
store.put(image, IMAGE_KEY);
|
|
815
|
+
store.put(stamp, STAMP_KEY);
|
|
816
|
+
wrote = true;
|
|
817
|
+
};
|
|
818
|
+
if (expected === undefined) {
|
|
819
|
+
write();
|
|
820
|
+
}
|
|
821
|
+
else {
|
|
822
|
+
const current = store.get(STAMP_KEY);
|
|
823
|
+
current.onsuccess = () => {
|
|
824
|
+
const found = typeof current.result === 'string' ? current.result : null;
|
|
825
|
+
if (found === expected)
|
|
826
|
+
write();
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
tx.oncomplete = () => resolve(wrote);
|
|
830
|
+
tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: saving "${this.dbName}" to IndexedDB was aborted.`));
|
|
831
|
+
}));
|
|
832
|
+
if (stored) {
|
|
833
|
+
this.loadedStamp = stamp;
|
|
834
|
+
this.savedGeneration = Math.max(this.savedGeneration, generation);
|
|
835
|
+
}
|
|
836
|
+
return stored;
|
|
837
|
+
}
|
|
838
|
+
/** A stamp no other save of this or another tab writes. */
|
|
839
|
+
nextStamp() {
|
|
840
|
+
this.stampCount += 1;
|
|
841
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}-${this.stampCount}`;
|
|
842
|
+
}
|
|
843
|
+
// ==========================================================================
|
|
844
|
+
// Saving
|
|
845
|
+
// ==========================================================================
|
|
846
|
+
/**
|
|
847
|
+
* Exports the database for a save, keeping the connection settings a
|
|
848
|
+
* caller made: sql.js's export() closes the connection and opens it again.
|
|
849
|
+
*/
|
|
850
|
+
exportImage() {
|
|
851
|
+
const settings = this.changedPragmas();
|
|
852
|
+
const image = this.db.export();
|
|
853
|
+
this.applyPragmas(settings);
|
|
854
|
+
return image;
|
|
855
|
+
}
|
|
856
|
+
/** The connection settings that differ from a fresh connection's. */
|
|
857
|
+
changedPragmas() {
|
|
858
|
+
const changed = new Map();
|
|
859
|
+
if (!this.db)
|
|
860
|
+
return changed;
|
|
861
|
+
for (const [name, value] of readPragmas(this.db)) {
|
|
862
|
+
if (this.pragmaDefaults.get(name) !== value)
|
|
863
|
+
changed.set(name, value);
|
|
864
|
+
}
|
|
865
|
+
return changed;
|
|
866
|
+
}
|
|
867
|
+
/** Sets connection settings on the database, after a save or a reload. */
|
|
868
|
+
applyPragmas(settings) {
|
|
869
|
+
for (const [name, value] of settings) {
|
|
870
|
+
const literal = typeof value === 'number' ? String(value) : `'${String(value).replace(/'/g, "''")}'`;
|
|
871
|
+
try {
|
|
872
|
+
this.db.exec(`PRAGMA ${name} = ${literal}`);
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
// A setting this build of SQLite does not take is left as it is.
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
483
879
|
/**
|
|
484
880
|
* Opens or creates the IndexedDB database.
|
|
485
881
|
*/
|
|
@@ -533,26 +929,28 @@ export class IndexedDbAdapter {
|
|
|
533
929
|
throw lastError;
|
|
534
930
|
}
|
|
535
931
|
/**
|
|
536
|
-
* Loads the SQLite database from IndexedDB
|
|
537
|
-
*
|
|
932
|
+
* Loads the SQLite database and its stamp from IndexedDB, in one
|
|
933
|
+
* transaction, so the two belong together.
|
|
538
934
|
*/
|
|
539
935
|
async loadFromIndexedDb() {
|
|
540
936
|
if (!this.idb)
|
|
541
|
-
return null;
|
|
542
|
-
return this.withTransactionRetry(
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
937
|
+
return { image: null, stamp: null };
|
|
938
|
+
return this.withTransactionRetry(() => new Promise((resolve, reject) => {
|
|
939
|
+
const tx = this.idb.transaction(this.storeName, 'readonly');
|
|
940
|
+
const store = tx.objectStore(this.storeName);
|
|
941
|
+
const image = store.get(IMAGE_KEY);
|
|
942
|
+
const stamp = store.get(STAMP_KEY);
|
|
943
|
+
tx.oncomplete = () => resolve({
|
|
944
|
+
image: image.result ?? null,
|
|
945
|
+
stamp: typeof stamp.result === 'string' ? stamp.result : null,
|
|
549
946
|
});
|
|
550
|
-
|
|
947
|
+
tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: reading "${this.dbName}" was aborted.`));
|
|
948
|
+
}));
|
|
551
949
|
}
|
|
552
950
|
/**
|
|
553
|
-
* Writes the current image to IndexedDB
|
|
554
|
-
* open (see saveIfIdle): sql.js's export() closes and
|
|
555
|
-
* which ends an open transaction.
|
|
951
|
+
* Writes the current image to IndexedDB, with a new stamp. Never called
|
|
952
|
+
* while a transaction is open (see saveIfIdle): sql.js's export() closes and
|
|
953
|
+
* reopens the connection, which ends an open transaction.
|
|
556
954
|
*
|
|
557
955
|
* Settles when the IndexedDB transaction completes, not when its put request
|
|
558
956
|
* succeeds: the transaction can still abort after that (a quota error at
|
|
@@ -563,16 +961,8 @@ export class IndexedDbAdapter {
|
|
|
563
961
|
async saveToIndexedDb() {
|
|
564
962
|
if (!this.idb || !this.db)
|
|
565
963
|
return;
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
await this.withTransactionRetry(() => new Promise((resolve, reject) => {
|
|
569
|
-
const tx = this.idb.transaction(this.storeName, 'readwrite');
|
|
570
|
-
tx.objectStore(this.storeName).put(data, 'db');
|
|
571
|
-
tx.oncomplete = () => resolve();
|
|
572
|
-
// A failed request aborts the transaction, so abort covers both.
|
|
573
|
-
tx.onabort = () => reject(tx.error ?? new Error(`IndexedDbAdapter: saving "${this.dbName}" to IndexedDB was aborted.`));
|
|
574
|
-
}));
|
|
575
|
-
this.savedGeneration = Math.max(this.savedGeneration, generation);
|
|
964
|
+
// exportImage() sets the connection settings again after the export.
|
|
965
|
+
await this.saveIfStampIs(undefined);
|
|
576
966
|
}
|
|
577
967
|
/** Whether writes were made since the image the last completed save exported. */
|
|
578
968
|
hasUnsavedWrites() {
|
|
@@ -617,11 +1007,42 @@ export class IndexedDbAdapter {
|
|
|
617
1007
|
}
|
|
618
1008
|
return this.startSave();
|
|
619
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Resolves once the writes up to `generation` are stored. A save in
|
|
1012
|
+
* progress that holds them is waited for; an open transaction() is waited
|
|
1013
|
+
* for, since its own save holds them; while a transaction a caller began
|
|
1014
|
+
* with BEGIN is open, nothing can be stored, and its COMMIT stores them.
|
|
1015
|
+
* Rejects when the save that held them failed.
|
|
1016
|
+
*/
|
|
1017
|
+
async saveThrough(generation) {
|
|
1018
|
+
while (this.savedGeneration < generation && this.db && this.idb) {
|
|
1019
|
+
if (this.saving) {
|
|
1020
|
+
const covering = this.savingGeneration >= generation;
|
|
1021
|
+
try {
|
|
1022
|
+
await this.saving;
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
if (covering)
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
continue;
|
|
1029
|
+
}
|
|
1030
|
+
if (this.gate.openToken() !== null) {
|
|
1031
|
+
await this.gate.ended();
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (this.inTransaction()) {
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
await this.startSave();
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
620
1040
|
/**
|
|
621
1041
|
* Starts a save at once and keeps it as the save in progress. The caller has
|
|
622
1042
|
* made sure no transaction is open: the export would end it.
|
|
623
1043
|
*/
|
|
624
1044
|
startSave() {
|
|
1045
|
+
this.savingGeneration = this.writeGeneration;
|
|
625
1046
|
const saving = this.saveToIndexedDb().finally(() => {
|
|
626
1047
|
if (this.saving === saving)
|
|
627
1048
|
this.saving = null;
|
|
@@ -643,6 +1064,14 @@ export class IndexedDbAdapter {
|
|
|
643
1064
|
"Inside a transaction() callback, run statements through the callback's handle: one run through the adapter " +
|
|
644
1065
|
'itself waits for the transaction, and the transaction waits for it.');
|
|
645
1066
|
}
|
|
1067
|
+
reportLockWait() {
|
|
1068
|
+
if (this.lockWaitReported)
|
|
1069
|
+
return;
|
|
1070
|
+
this.lockWaitReported = true;
|
|
1071
|
+
console.warn(`[IndexedDbAdapter] A statement on "${this.dbName}" has waited ${LONG_WAIT_MS / 1000} s for its turn across tabs: ` +
|
|
1072
|
+
"another tab's transaction holds the lock, or a transaction() of this tab whose callback runs a statement through " +
|
|
1073
|
+
"the adapter itself instead of the callback's handle waits on itself.");
|
|
1074
|
+
}
|
|
646
1075
|
/**
|
|
647
1076
|
* Wraps a transaction operation with retry logic.
|
|
648
1077
|
* Handles "NotFoundError" by reopening IndexedDB connection.
|
|
@@ -718,12 +1147,20 @@ export class IndexedDbAdapter {
|
|
|
718
1147
|
}
|
|
719
1148
|
return null;
|
|
720
1149
|
}
|
|
721
|
-
/**
|
|
722
|
-
* Prepares a SQL statement for execution.
|
|
723
|
-
*/
|
|
724
|
-
prepareInternal(statement) {
|
|
725
|
-
this.ensureOpen();
|
|
726
|
-
return this.db.prepare(statement);
|
|
727
|
-
}
|
|
728
1150
|
}
|
|
1151
|
+
/** The connection settings of `db`, by name. A setting this build does not have is left out. */
|
|
1152
|
+
const readPragmas = (db) => {
|
|
1153
|
+
const values = new Map();
|
|
1154
|
+
for (const name of CONNECTION_PRAGMAS) {
|
|
1155
|
+
try {
|
|
1156
|
+
const value = db.exec(`PRAGMA ${name}`)[0]?.values?.[0]?.[0];
|
|
1157
|
+
if (value !== undefined)
|
|
1158
|
+
values.set(name, value);
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
// Not in this build of SQLite.
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return values;
|
|
1165
|
+
};
|
|
729
1166
|
//# sourceMappingURL=indexedDbAdapter.js.map
|