deepbase-sqlite 3.6.9 → 3.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 CHANGED
@@ -49,6 +49,12 @@ new SqliteDriver({
49
49
  path: './data', // Directory to store database files
50
50
  name: 'default', // Database filename (without .db)
51
51
  pragma: 'balanced', // Performance profile: 'none' | 'safe' | 'balanced' | 'fast'
52
+ busyTimeoutMs: 5000, // Native wait per lock attempt
53
+ busyRetry: {
54
+ maxAttempts: 2, // Total transaction attempts
55
+ baseDelayMs: 25, // Exponential backoff base
56
+ maxDelayMs: 250 // Backoff cap
57
+ },
52
58
  nidAlphabet: 'ABC...', // Alphabet for ID generation
53
59
  nidLength: 10 // Length of generated IDs
54
60
  })
@@ -64,16 +70,20 @@ Uses `better-sqlite3` for synchronous operations wrapped in async API:
64
70
  - Transaction support for batch operations
65
71
  - Fast lookups with indexed keys
66
72
 
67
- ### Singleton Pattern
73
+ ### Multi-process concurrency
68
74
 
69
- Multiple instances pointing to the same database file will share the same connection:
75
+ Multiple instances and same-host processes may safely point to the same database file. Each driver owns its connection and lifecycle; SQLite coordinates writers using WAL, `BEGIN IMMEDIATE`, a busy timeout, and bounded transaction retries:
70
76
 
71
77
  ```javascript
72
78
  const db1 = new DeepBase(new SqliteDriver({ name: 'mydb' }));
73
79
  const db2 = new DeepBase(new SqliteDriver({ name: 'mydb' }));
74
- // Both use the same underlying database connection
80
+ // Independent connections; disconnecting db1 does not close db2.
75
81
  ```
76
82
 
83
+ SQLite still permits only one writer at a time. Keep write transactions short and use a client-server database when sustained write contention or multiple hosts are required. WAL requires a local filesystem shared by processes on the same host; do not place the database on NFS.
84
+
85
+ When upgrading from a version that used the in-memory sequence counter, stop all old writer processes before starting the new version. The schema migration is automatic, but old and new sequence allocators must not write concurrently during a rolling deployment.
86
+
77
87
  ### Nested Data Structure
78
88
 
79
89
  Efficiently stores nested objects using a key-value schema:
@@ -82,7 +92,7 @@ Efficiently stores nested objects using a key-value schema:
82
92
  - Values are stored as JSON
83
93
  - Fast lookups for both exact keys and partial paths
84
94
 
85
- Each row also stores a monotonic `seq` so reads that rebuild objects use `ORDER BY seq, key`. That matches JavaScript insertion order for sibling keys and keeps `shift()` / `pop()` aligned with `JsonDriver`. The driver now exposes `first()` / `last()` using SQL boundary queries in the same order semantics as `keys()`. Existing databases pick up `seq` via `ALTER TABLE` on connect (legacy rows default to `0`, then tie-break by `key`).
95
+ Each row also stores a monotonic, database-assigned `seq` so reads that rebuild objects use `ORDER BY seq, key`. That matches JavaScript insertion order for sibling keys and keeps `shift()` / `pop()` aligned with `JsonDriver`. Existing databases migrate automatically inside an atomic `BEGIN IMMEDIATE` transaction. Legacy and duplicate sequence values are normalized while preserving their previous `ORDER BY seq, key` order.
86
96
 
87
97
  ### ACID Compliance
88
98
 
@@ -90,7 +100,7 @@ SQLite provides:
90
100
 
91
101
  - **Atomicity**: All operations complete or none do
92
102
  - **Consistency**: Data remains valid across transactions
93
- - **Isolation**: Concurrent operations don't interfere
103
+ - **Isolation**: Concurrent writes are serialized by SQLite
94
104
  - **Durability**: Committed data persists even after crashes
95
105
 
96
106
  ## Pragma Modes
@@ -104,7 +114,7 @@ SQLite provides:
104
114
  | **balanced** *(default)* | NORMAL | 8 MB | 256 MB | Yes | Best mix of speed and safety for most apps |
105
115
  | **fast** | OFF | 16 MB | 256 MB | Yes | Maximum throughput — data may be lost on OS crash |
106
116
 
107
- All WAL modes use `journal_mode=WAL`, `temp_store=MEMORY`, and `busy_timeout=5000ms`.
117
+ All WAL modes use `journal_mode=WAL` and `temp_store=MEMORY`. Lock waiting is configured independently through `busyTimeoutMs` and therefore also applies to `pragma: 'none'`.
108
118
 
109
119
  ```javascript
110
120
  // Backward-compatible (no PRAGMAs, no WITHOUT ROWID)
@@ -132,30 +142,33 @@ import { SqliteFastDriver } from 'deepbase-sqlite';
132
142
  Data is stored in a simple key-value table:
133
143
 
134
144
  ```sql
135
- -- pragma: 'none' (legacy-compatible)
136
145
  CREATE TABLE deepbase (
137
146
  key TEXT PRIMARY KEY,
138
- value TEXT NOT NULL
139
- )
147
+ value TEXT NOT NULL,
148
+ seq INTEGER NOT NULL
149
+ );
140
150
 
141
- -- pragma: 'safe' | 'balanced' | 'fast' (optimized)
142
- CREATE TABLE deepbase (
151
+ CREATE UNIQUE INDEX deepbase_seq_unique ON deepbase(seq);
152
+
153
+ CREATE TABLE deepbase_meta (
143
154
  key TEXT PRIMARY KEY,
144
- value TEXT NOT NULL
145
- ) WITHOUT ROWID
155
+ value INTEGER NOT NULL
156
+ );
146
157
  ```
147
158
 
159
+ `deepbase_meta` is always created `WITHOUT ROWID`; optimized PRAGMA profiles do the same for `deepbase`. The metadata table tracks the internal schema version, while user data remains exclusively in `deepbase`.
160
+
148
161
  Example data:
149
162
 
150
163
  ```
151
- key | value
152
- ----------------------|------------------
153
- users.alice.name | "Alice"
154
- users.alice.age | 30
155
- users.bob.name | "Bob"
156
- users.bob.age | 25
157
- config.theme | "dark"
158
- config.lang | "en"
164
+ key | value | seq
165
+ ----------------------|----------|----
166
+ users.alice.name | "Alice" | 1
167
+ users.alice.age | 30 | 2
168
+ users.bob.name | "Bob" | 3
169
+ users.bob.age | 25 | 4
170
+ config.theme | "dark" | 5
171
+ config.lang | "en" | 6
159
172
  ```
160
173
 
161
174
  ## Use Cases
@@ -271,6 +284,17 @@ await db.set(`user_${userId}_profile`, data);
271
284
 
272
285
  ## Troubleshooting
273
286
 
287
+ ### `SQLITE_BUSY` / `database is locked`
288
+
289
+ The driver waits and retries complete transactions when another connection owns the write lock. If the retry budget is exhausted:
290
+
291
+ 1. Confirm every process points to the same local filesystem, not NFS.
292
+ 2. Look for long-running migrations, raw `better-sqlite3` connections, SQLite tools, or overlapping deployments.
293
+ 3. Increase `busyTimeoutMs` or `busyRetry.maxAttempts` only for known transient contention.
294
+ 4. Move to a client-server database if write contention is sustained.
295
+
296
+ `better-sqlite3` is synchronous. Each native lock wait blocks that Node.js thread for up to `busyTimeoutMs`; the JavaScript backoff between attempts is asynchronous.
297
+
274
298
  ### `Could not locate the bindings file` / `better_sqlite3.node` missing
275
299
 
276
300
  This error means `better-sqlite3`'s native binding was never fetched or built.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepbase-sqlite",
3
- "version": "3.6.9",
3
+ "version": "3.7.0",
4
4
  "description": "⚡ DeepBase SQLite - SQLite database driver",
5
5
  "type": "module",
6
6
  "main": "src/index.cjs",
@@ -17,10 +17,7 @@
17
17
  "better-sqlite3": "^11.8.1"
18
18
  },
19
19
  "peerDependencies": {
20
- "deepbase": "^3.6.9"
21
- },
22
- "scripts": {
23
- "test": "mocha test/test.js"
20
+ "deepbase": "^3.7.0"
24
21
  },
25
22
  "devDependencies": {
26
23
  "mocha": "^10.8.2"
@@ -44,5 +41,8 @@
44
41
  "bugs": {
45
42
  "url": "https://github.com/clasen/DeepBase/issues"
46
43
  },
47
- "homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-sqlite"
48
- }
44
+ "homepage": "https://github.com/clasen/DeepBase/tree/main/packages/driver-sqlite",
45
+ "scripts": {
46
+ "test": "mocha test/test.js test/test-multiprocess.js"
47
+ }
48
+ }
@@ -2,55 +2,28 @@ import { DeepBaseDriver } from 'deepbase';
2
2
  import Database from 'better-sqlite3';
3
3
  import fs from 'fs';
4
4
  import * as pathModule from 'path';
5
-
6
- const PRAGMA = {
7
- none: null,
8
- safe: {
9
- journal_mode: 'WAL',
10
- synchronous: 'FULL',
11
- temp_store: 'MEMORY',
12
- cache_size: -2000,
13
- busy_timeout: 5000,
14
- mmap_size: 0,
15
- },
16
- balanced: {
17
- journal_mode: 'WAL',
18
- synchronous: 'NORMAL',
19
- temp_store: 'MEMORY',
20
- cache_size: -8000,
21
- busy_timeout: 5000,
22
- mmap_size: 268435456,
23
- },
24
- fast: {
25
- journal_mode: 'WAL',
26
- synchronous: 'OFF',
27
- temp_store: 'MEMORY',
28
- cache_size: -16000,
29
- busy_timeout: 5000,
30
- mmap_size: 268435456,
31
- },
32
- };
5
+ import { withBusyRetry } from './busy.js';
6
+ import { resolveSqliteConfig } from './config.js';
7
+ import { migrateSchema } from './schema.js';
33
8
 
34
9
  export class SqliteDriver extends DeepBaseDriver {
35
- static _instances = {};
36
-
37
- constructor({ name, path, pragma, ...opts } = {}) {
10
+ constructor({ name, path, pragma, busyTimeoutMs, busyRetry, ...opts } = {}) {
38
11
  super(opts);
39
12
 
13
+ const config = resolveSqliteConfig({ pragma, busyTimeoutMs, busyRetry });
40
14
  this.name = name || 'default';
41
15
  this.path = path || pathModule.join(process.cwd(), 'db');
42
- this.pragma = pragma || 'balanced';
16
+ this.pragma = config.pragma;
17
+ this.pragmaConfig = config.pragmaConfig;
18
+ this.busyTimeoutMs = config.busyTimeoutMs;
19
+ this.busyRetry = config.busyRetry;
43
20
 
44
21
  this.path = pathModule.resolve(this.path);
45
22
  this.fileName = pathModule.join(this.path, `${this.name}.db`);
46
23
 
47
- if (SqliteDriver._instances[this.fileName]) {
48
- return SqliteDriver._instances[this.fileName];
49
- }
50
-
51
24
  this.db = null;
52
- this._nextSeq = 1;
53
- SqliteDriver._instances[this.fileName] = this;
25
+ this._connectPromise = null;
26
+ this._writeQueue = Promise.resolve();
54
27
  }
55
28
 
56
29
  _connectSync() {
@@ -61,7 +34,7 @@ export class SqliteDriver extends DeepBaseDriver {
61
34
  }
62
35
 
63
36
  try {
64
- this.db = new Database(this.fileName);
37
+ this.db = new Database(this.fileName, { timeout: this.busyTimeoutMs });
65
38
  } catch (err) {
66
39
  if (this._isMissingNativeBinding(err)) {
67
40
  const hint =
@@ -78,35 +51,22 @@ export class SqliteDriver extends DeepBaseDriver {
78
51
  throw err;
79
52
  }
80
53
 
81
- const cfg = PRAGMA[this.pragma];
54
+ const cfg = this.pragmaConfig;
82
55
  if (cfg) {
83
56
  this.db.pragma(`journal_mode = ${cfg.journal_mode}`);
84
57
  this.db.pragma(`synchronous = ${cfg.synchronous}`);
85
58
  this.db.pragma(`temp_store = ${cfg.temp_store}`);
86
59
  this.db.pragma(`cache_size = ${cfg.cache_size}`);
87
- this.db.pragma(`busy_timeout = ${cfg.busy_timeout}`);
88
60
  this.db.pragma(`mmap_size = ${cfg.mmap_size}`);
89
61
  }
90
62
 
91
63
  const withoutRowid = cfg ? ' WITHOUT ROWID' : '';
92
- this.db.exec(`
93
- CREATE TABLE IF NOT EXISTS deepbase (
94
- key TEXT PRIMARY KEY,
95
- value TEXT NOT NULL,
96
- seq INTEGER NOT NULL DEFAULT 0
97
- )${withoutRowid}
98
- `);
99
-
100
- const tableCols = this.db.prepare('PRAGMA table_info(deepbase)').all();
101
- if (!tableCols.some((c) => c.name === 'seq')) {
102
- this.db.exec('ALTER TABLE deepbase ADD COLUMN seq INTEGER NOT NULL DEFAULT 0');
103
- }
64
+ migrateSchema(this.db, { withoutRowid });
104
65
 
105
66
  this.getStmt = this.db.prepare('SELECT value FROM deepbase WHERE key = ?');
106
- this.getMaxSeqStmt = this.db.prepare('SELECT IFNULL(MAX(seq), 0) AS maxSeq FROM deepbase');
107
67
  this.setStmt = this.db.prepare(`
108
68
  INSERT INTO deepbase (key, value, seq)
109
- VALUES (?, ?, ?)
69
+ VALUES (?, ?, (SELECT IFNULL(MAX(seq), 0) + 1 FROM deepbase))
110
70
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
111
71
  `);
112
72
  this.delStmt = this.db.prepare('DELETE FROM deepbase WHERE key = ?');
@@ -123,18 +83,20 @@ export class SqliteDriver extends DeepBaseDriver {
123
83
  this.delChildrenStmt = this.db.prepare("DELETE FROM deepbase WHERE key LIKE ? ESCAPE '!'");
124
84
  this.hasChildrenStmt = this.db.prepare("SELECT 1 FROM deepbase WHERE key LIKE ? ESCAPE '!' LIMIT 1");
125
85
 
126
- this._setTxn = this.db.transaction((key, jsonValue, keys) => {
86
+ const setTxn = this.db.transaction((key, jsonValue, keys) => {
127
87
  this._expandParentObjects(keys);
128
88
  this._replaceRow(key, jsonValue);
129
89
  });
90
+ this._setTxn = (...args) => setTxn.immediate(...args);
130
91
 
131
- this._delTxn = this.db.transaction((key, likePattern, keys) => {
92
+ const delTxn = this.db.transaction((key, likePattern, keys) => {
132
93
  this._expandParentObjects(keys);
133
94
  this.delStmt.run(key);
134
95
  this.delChildrenStmt.run(likePattern);
135
96
  });
97
+ this._delTxn = (...args) => delTxn.immediate(...args);
136
98
 
137
- this._updTxn = this.db.transaction((keys, func) => {
99
+ const updTxn = this.db.transaction((keys, func) => {
138
100
  const currentValue = this._getSync(keys);
139
101
  const newValue = func(currentValue);
140
102
  const key = this._pathToKey(keys);
@@ -142,37 +104,84 @@ export class SqliteDriver extends DeepBaseDriver {
142
104
  this._replaceRow(key, JSON.stringify(newValue));
143
105
  return keys;
144
106
  });
107
+ this._updTxn = (...args) => updTxn.immediate(...args);
145
108
 
146
- this._setRootTxn = this.db.transaction((entries) => {
109
+ const setRootTxn = this.db.transaction((entries) => {
147
110
  this.db.exec('DELETE FROM deepbase');
148
- this._nextSeq = 1;
149
111
  for (const [key, value] of entries) {
150
- this.setStmt.run(key, JSON.stringify(value), this._consumeSeq());
112
+ this.setStmt.run(key, JSON.stringify(value));
151
113
  }
152
114
  });
115
+ this._setRootTxn = (...args) => setRootTxn.immediate(...args);
116
+
117
+ const clearTxn = this.db.transaction(() => {
118
+ this.db.exec('DELETE FROM deepbase');
119
+ });
120
+ this._clearTxn = (...args) => clearTxn.immediate(...args);
153
121
 
154
- this._nextSeq = Number(this.getMaxSeqStmt.get()?.maxSeq || 0) + 1;
155
122
  this._connected = true;
156
123
  }
157
124
 
158
125
  async connect() {
159
- this._connectSync();
126
+ if (this._connected) return;
127
+ if (!this._connectPromise) {
128
+ this._connectPromise = withBusyRetry(() => this._openSync(), this.busyRetry)
129
+ .finally(() => {
130
+ this._connectPromise = null;
131
+ });
132
+ }
133
+ return this._connectPromise;
134
+ }
135
+
136
+ _openSync() {
137
+ try {
138
+ this._connectSync();
139
+ } catch (error) {
140
+ this._closeConnection();
141
+ throw error;
142
+ }
160
143
  }
161
144
 
162
145
  async disconnect() {
146
+ return this._queueWrite(async () => {
147
+ if (this._connectPromise) {
148
+ await this._connectPromise;
149
+ }
150
+ this._closeConnection();
151
+ });
152
+ }
153
+
154
+ _closeConnection() {
163
155
  if (this.db) {
164
156
  this.db.close();
165
- this.db = null;
166
157
  }
158
+ this.db = null;
167
159
  this._connected = false;
168
160
  }
169
161
 
162
+ _queueWrite(operation) {
163
+ const result = this._writeQueue.then(operation, operation);
164
+ this._writeQueue = result.then(
165
+ () => undefined,
166
+ () => undefined,
167
+ );
168
+ return result;
169
+ }
170
+
171
+ _runWrite(operation) {
172
+ return this._queueWrite(async () => {
173
+ await this.connect();
174
+ return withBusyRetry(operation, this.busyRetry);
175
+ });
176
+ }
177
+
170
178
  async get(...args) {
179
+ await this.connect();
171
180
  return this._getSync(args);
172
181
  }
173
182
 
174
183
  getSync(...args) {
175
- this._connectSync();
184
+ this._openSync();
176
185
  return this._getSync(args);
177
186
  }
178
187
 
@@ -205,35 +214,40 @@ export class SqliteDriver extends DeepBaseDriver {
205
214
  }
206
215
 
207
216
  if (args.length === 1) {
208
- await this._setRootObject(args[0]);
209
- return [];
217
+ const entries = this._flattenObject(args[0]);
218
+ return this._runWrite(() => {
219
+ this._setRootTxn(entries);
220
+ return [];
221
+ });
210
222
  }
211
223
 
212
- return this._setSync(args);
213
- }
214
-
215
- _setSync(args) {
216
224
  const keys = args.slice(0, -1);
217
225
  const value = args[args.length - 1];
218
226
  const key = this._pathToKey(keys);
219
- this._setTxn(key, JSON.stringify(value), keys);
220
- return keys;
227
+ const jsonValue = JSON.stringify(value);
228
+ return this._runWrite(() => {
229
+ this._setTxn(key, jsonValue, keys);
230
+ return keys;
231
+ });
221
232
  }
222
233
 
223
234
  _replaceRow(key, jsonValue) {
224
235
  this.delChildrenStmt.run(this._likePrefix(key));
225
- this.setStmt.run(key, jsonValue, this._consumeSeq());
236
+ this.setStmt.run(key, jsonValue);
226
237
  }
227
238
 
228
239
  async del(...keys) {
229
240
  if (keys.length === 0) {
230
- this.db.exec('DELETE FROM deepbase');
231
- this._nextSeq = 1;
232
- return;
241
+ return this._runWrite(() => {
242
+ this._clearTxn();
243
+ });
233
244
  }
234
245
 
235
246
  const key = this._pathToKey(keys);
236
- this._delTxn(key, this._likePrefix(key), keys);
247
+ const likePattern = this._likePrefix(key);
248
+ return this._runWrite(() => {
249
+ this._delTxn(key, likePattern, keys);
250
+ });
237
251
  }
238
252
 
239
253
  async inc(...args) {
@@ -256,14 +270,16 @@ export class SqliteDriver extends DeepBaseDriver {
256
270
  async upd(...args) {
257
271
  const func = args.pop();
258
272
  const keys = args;
259
- return this._updTxn(keys, func);
273
+ return this._runWrite(() => this._updTxn(keys, func));
260
274
  }
261
275
 
262
276
  async first(...args) {
277
+ await this.connect();
263
278
  return this._firstOrLastKey(args, false);
264
279
  }
265
280
 
266
281
  async last(...args) {
282
+ await this.connect();
267
283
  return this._firstOrLastKey(args, true);
268
284
  }
269
285
 
@@ -310,11 +326,6 @@ export class SqliteDriver extends DeepBaseDriver {
310
326
  return result;
311
327
  }
312
328
 
313
- async _setRootObject(obj) {
314
- const entries = this._flattenObject(obj);
315
- this._setRootTxn(entries);
316
- }
317
-
318
329
  _buildObjectFromChildren(parentKey, likePattern) {
319
330
  const rows = this.getKeysLikeStmt.all(likePattern || (parentKey ? this._likePrefix(parentKey) : '%'));
320
331
  const result = {};
@@ -352,12 +363,24 @@ export class SqliteDriver extends DeepBaseDriver {
352
363
  if (path.length === 0) return;
353
364
 
354
365
  if (path.length === 1) {
355
- obj[path[0]] = value;
366
+ const key = path[0];
367
+ const existing = obj[key];
368
+ if (
369
+ value === null &&
370
+ existing !== null &&
371
+ typeof existing === 'object' &&
372
+ !Array.isArray(existing) &&
373
+ Object.keys(existing).length > 0
374
+ ) {
375
+ return;
376
+ }
377
+ obj[key] = value;
356
378
  return;
357
379
  }
358
380
 
359
381
  const key = path[0];
360
- if (!obj.hasOwnProperty(key) || typeof obj[key] !== 'object') {
382
+ const current = obj[key];
383
+ if (!obj.hasOwnProperty(key) || current === null || typeof current !== 'object' || Array.isArray(current)) {
361
384
  obj[key] = {};
362
385
  }
363
386
 
@@ -422,18 +445,12 @@ export class SqliteDriver extends DeepBaseDriver {
422
445
 
423
446
  const entries = this._flattenObject(parentValue, parentKey);
424
447
  for (const [key, value] of entries) {
425
- this.setStmt.run(key, JSON.stringify(value), this._consumeSeq());
448
+ this.setStmt.run(key, JSON.stringify(value));
426
449
  }
427
450
  }
428
451
  }
429
452
  }
430
453
  }
431
-
432
- _consumeSeq() {
433
- const seq = this._nextSeq;
434
- this._nextSeq += 1;
435
- return seq;
436
- }
437
454
  }
438
455
 
439
456
  export default SqliteDriver;
package/src/busy.js ADDED
@@ -0,0 +1,28 @@
1
+ export function isSqliteBusyError(error) {
2
+ return typeof error?.code === 'string' && error.code.startsWith('SQLITE_BUSY');
3
+ }
4
+
5
+ function retryDelayMs(attempt, { baseDelayMs, maxDelayMs }) {
6
+ const cap = Math.min(maxDelayMs, baseDelayMs * (2 ** Math.max(0, attempt - 1)));
7
+ if (cap === 0) return 0;
8
+ return Math.floor(Math.random() * (cap + 1));
9
+ }
10
+
11
+ function delay(ms) {
12
+ return new Promise(resolve => setTimeout(resolve, ms));
13
+ }
14
+
15
+ export async function withBusyRetry(operation, config) {
16
+ for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
17
+ try {
18
+ return operation();
19
+ } catch (error) {
20
+ if (!isSqliteBusyError(error) || attempt === config.maxAttempts) {
21
+ throw error;
22
+ }
23
+ await delay(retryDelayMs(attempt, config));
24
+ }
25
+ }
26
+
27
+ throw new Error('Unreachable busy retry state');
28
+ }
package/src/config.js ADDED
@@ -0,0 +1,78 @@
1
+ const PRAGMA_PROFILES = Object.freeze({
2
+ none: null,
3
+ safe: Object.freeze({
4
+ journal_mode: 'WAL',
5
+ synchronous: 'FULL',
6
+ temp_store: 'MEMORY',
7
+ cache_size: -2000,
8
+ mmap_size: 0,
9
+ }),
10
+ balanced: Object.freeze({
11
+ journal_mode: 'WAL',
12
+ synchronous: 'NORMAL',
13
+ temp_store: 'MEMORY',
14
+ cache_size: -8000,
15
+ mmap_size: 268435456,
16
+ }),
17
+ fast: Object.freeze({
18
+ journal_mode: 'WAL',
19
+ synchronous: 'OFF',
20
+ temp_store: 'MEMORY',
21
+ cache_size: -16000,
22
+ mmap_size: 268435456,
23
+ }),
24
+ });
25
+
26
+ export const SQLITE_CONFIG = Object.freeze({
27
+ defaultPragma: 'balanced',
28
+ schemaVersion: 1,
29
+ busyTimeoutMs: 5000,
30
+ busyRetry: Object.freeze({
31
+ maxAttempts: 2,
32
+ baseDelayMs: 25,
33
+ maxDelayMs: 250,
34
+ }),
35
+ pragmaProfiles: PRAGMA_PROFILES,
36
+ });
37
+
38
+ function assertNonNegativeInteger(name, value) {
39
+ if (!Number.isInteger(value) || value < 0) {
40
+ throw new TypeError(`${name} must be a non-negative integer`);
41
+ }
42
+ }
43
+
44
+ export function resolveSqliteConfig({ pragma, busyTimeoutMs, busyRetry } = {}) {
45
+ const resolvedPragma = pragma ?? SQLITE_CONFIG.defaultPragma;
46
+ if (!Object.prototype.hasOwnProperty.call(SQLITE_CONFIG.pragmaProfiles, resolvedPragma)) {
47
+ throw new TypeError(`pragma must be one of: ${Object.keys(SQLITE_CONFIG.pragmaProfiles).join(', ')}`);
48
+ }
49
+
50
+ const resolvedBusyTimeoutMs = busyTimeoutMs ?? SQLITE_CONFIG.busyTimeoutMs;
51
+ assertNonNegativeInteger('busyTimeoutMs', resolvedBusyTimeoutMs);
52
+
53
+ if (busyRetry !== undefined && (busyRetry === null || typeof busyRetry !== 'object' || Array.isArray(busyRetry))) {
54
+ throw new TypeError('busyRetry must be an object');
55
+ }
56
+
57
+ const resolvedBusyRetry = {
58
+ ...SQLITE_CONFIG.busyRetry,
59
+ ...(busyRetry ?? {}),
60
+ };
61
+ assertNonNegativeInteger('busyRetry.maxAttempts', resolvedBusyRetry.maxAttempts);
62
+ assertNonNegativeInteger('busyRetry.baseDelayMs', resolvedBusyRetry.baseDelayMs);
63
+ assertNonNegativeInteger('busyRetry.maxDelayMs', resolvedBusyRetry.maxDelayMs);
64
+
65
+ if (resolvedBusyRetry.maxAttempts < 1) {
66
+ throw new TypeError('busyRetry.maxAttempts must be at least 1');
67
+ }
68
+ if (resolvedBusyRetry.maxDelayMs < resolvedBusyRetry.baseDelayMs) {
69
+ throw new TypeError('busyRetry.maxDelayMs must be greater than or equal to busyRetry.baseDelayMs');
70
+ }
71
+
72
+ return {
73
+ pragma: resolvedPragma,
74
+ pragmaConfig: SQLITE_CONFIG.pragmaProfiles[resolvedPragma],
75
+ busyTimeoutMs: resolvedBusyTimeoutMs,
76
+ busyRetry: resolvedBusyRetry,
77
+ };
78
+ }
package/src/index.d.ts CHANGED
@@ -1,9 +1,17 @@
1
1
  import { DeepBaseDriver, DeepBaseDriverOptions } from 'deepbase';
2
2
 
3
+ export interface SqliteBusyRetryOptions {
4
+ maxAttempts?: number;
5
+ baseDelayMs?: number;
6
+ maxDelayMs?: number;
7
+ }
8
+
3
9
  export interface SqliteDriverOptions extends DeepBaseDriverOptions {
4
10
  name?: string;
5
11
  path?: string;
6
12
  pragma?: 'none' | 'safe' | 'balanced' | 'fast';
13
+ busyTimeoutMs?: number;
14
+ busyRetry?: SqliteBusyRetryOptions;
7
15
  }
8
16
 
9
17
  export class SqliteDriver extends DeepBaseDriver {
@@ -13,6 +21,8 @@ export class SqliteDriver extends DeepBaseDriver {
13
21
  path: string;
14
22
  fileName: string;
15
23
  pragma: string;
24
+ busyTimeoutMs: number;
25
+ busyRetry: Required<SqliteBusyRetryOptions>;
16
26
  }
17
27
 
18
28
  export { SqliteDriver as SqliteFastDriver };
package/src/schema.js ADDED
@@ -0,0 +1,79 @@
1
+ import { SQLITE_CONFIG } from './config.js';
2
+
3
+ const SCHEMA_VERSION_KEY = 'schema_version';
4
+
5
+ function createBaseSchema(db, withoutRowid) {
6
+ db.exec(`
7
+ CREATE TABLE IF NOT EXISTS deepbase (
8
+ key TEXT PRIMARY KEY,
9
+ value TEXT NOT NULL,
10
+ seq INTEGER NOT NULL DEFAULT 0
11
+ )${withoutRowid}
12
+ `);
13
+
14
+ const columns = db.prepare('PRAGMA table_info(deepbase)').all();
15
+ if (!columns.some(column => column.name === 'seq')) {
16
+ db.exec('ALTER TABLE deepbase ADD COLUMN seq INTEGER NOT NULL DEFAULT 0');
17
+ }
18
+
19
+ db.exec(`
20
+ CREATE TABLE IF NOT EXISTS deepbase_meta (
21
+ key TEXT PRIMARY KEY,
22
+ value INTEGER NOT NULL
23
+ ) WITHOUT ROWID
24
+ `);
25
+ }
26
+
27
+ function readSchemaVersion(db) {
28
+ const row = db.prepare('SELECT value FROM deepbase_meta WHERE key = ?').get(SCHEMA_VERSION_KEY);
29
+ return Number(row?.value ?? 0);
30
+ }
31
+
32
+ function normalizeSequence(db) {
33
+ const nonPositive = db.prepare('SELECT 1 FROM deepbase WHERE seq < 1 LIMIT 1').get();
34
+ const needsNormalization = nonPositive || db.prepare(`
35
+ SELECT 1
36
+ FROM deepbase
37
+ GROUP BY seq
38
+ HAVING COUNT(*) > 1
39
+ LIMIT 1
40
+ `).get();
41
+ if (!needsNormalization) return;
42
+
43
+ const rows = db.prepare('SELECT key FROM deepbase ORDER BY seq, key').all();
44
+ const update = db.prepare('UPDATE deepbase SET seq = ? WHERE key = ?');
45
+
46
+ rows.forEach((row, index) => {
47
+ update.run(index + 1, row.key);
48
+ });
49
+ }
50
+
51
+ function writeSchemaVersion(db) {
52
+ db.prepare(`
53
+ INSERT INTO deepbase_meta (key, value)
54
+ VALUES (?, ?)
55
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
56
+ `).run(SCHEMA_VERSION_KEY, SQLITE_CONFIG.schemaVersion);
57
+ }
58
+
59
+ export function migrateSchema(db, { withoutRowid = '' } = {}) {
60
+ const migration = db.transaction(() => {
61
+ createBaseSchema(db, withoutRowid);
62
+
63
+ const version = readSchemaVersion(db);
64
+ if (version > SQLITE_CONFIG.schemaVersion) {
65
+ throw new Error(
66
+ `deepbase-sqlite schema version ${version} is newer than supported version ${SQLITE_CONFIG.schemaVersion}`,
67
+ );
68
+ }
69
+
70
+ if (version < SQLITE_CONFIG.schemaVersion) {
71
+ normalizeSequence(db);
72
+ writeSchemaVersion(db);
73
+ }
74
+
75
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS deepbase_seq_unique ON deepbase(seq)');
76
+ });
77
+
78
+ migration.immediate();
79
+ }
@@ -0,0 +1,57 @@
1
+ import Database from 'better-sqlite3';
2
+
3
+ export function createLegacyDatabase(fileName, rows) {
4
+ const db = new Database(fileName);
5
+ db.exec('CREATE TABLE deepbase (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
6
+ const insert = db.prepare('INSERT INTO deepbase (key, value) VALUES (?, ?)');
7
+ const insertRows = db.transaction((entries) => {
8
+ for (const [key, value] of entries) {
9
+ insert.run(key, JSON.stringify(value));
10
+ }
11
+ });
12
+ insertRows(rows);
13
+ db.close();
14
+ }
15
+
16
+ export function createSequencedDatabase(fileName, rows, { failMigration = false } = {}) {
17
+ const db = new Database(fileName);
18
+ db.exec(`
19
+ CREATE TABLE deepbase (
20
+ key TEXT PRIMARY KEY,
21
+ value TEXT NOT NULL,
22
+ seq INTEGER NOT NULL DEFAULT 0
23
+ )
24
+ `);
25
+ const insert = db.prepare('INSERT INTO deepbase (key, value, seq) VALUES (?, ?, ?)');
26
+ const insertRows = db.transaction((entries) => {
27
+ for (const [key, value, seq] of entries) {
28
+ insert.run(key, JSON.stringify(value), seq);
29
+ }
30
+ });
31
+ insertRows(rows);
32
+
33
+ if (failMigration) {
34
+ db.exec(`
35
+ CREATE TRIGGER fail_seq_migration
36
+ BEFORE UPDATE OF seq ON deepbase
37
+ BEGIN
38
+ SELECT RAISE(ABORT, 'forced migration failure');
39
+ END
40
+ `);
41
+ }
42
+ db.close();
43
+ }
44
+
45
+ export function inspectDatabase(fileName) {
46
+ const db = new Database(fileName);
47
+ const rows = db.prepare('SELECT key, seq FROM deepbase ORDER BY seq, key').all();
48
+ const indexes = db.prepare("PRAGMA index_list('deepbase')").all();
49
+ const metaTable = db.prepare(
50
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'deepbase_meta'",
51
+ ).get();
52
+ const schemaVersion = metaTable
53
+ ? db.prepare("SELECT value FROM deepbase_meta WHERE key = 'schema_version'").get()?.value
54
+ : undefined;
55
+ db.close();
56
+ return { rows, indexes, schemaVersion };
57
+ }
@@ -0,0 +1,265 @@
1
+ import assert from 'assert';
2
+ import { fork } from 'child_process';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import Database from 'better-sqlite3';
7
+ import { SqliteDriver } from '../src/SqliteDriver.js';
8
+ import { createLegacyDatabase, createSequencedDatabase, inspectDatabase } from './fixtures.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const workerPath = path.join(__dirname, 'worker.js');
12
+ const testDataPath = path.join(__dirname, 'test-data-multiprocess');
13
+ const retryOptions = {
14
+ busyTimeoutMs: 50,
15
+ busyRetry: {
16
+ maxAttempts: 20,
17
+ baseDelayMs: 2,
18
+ maxDelayMs: 20,
19
+ },
20
+ };
21
+
22
+ function createWorker(args) {
23
+ const child = fork(workerPath, [JSON.stringify(args)], {
24
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
25
+ });
26
+ let stderr = '';
27
+ child.stderr.on('data', data => {
28
+ stderr += data.toString();
29
+ });
30
+ const done = new Promise((resolve, reject) => {
31
+ child.on('error', reject);
32
+ child.on('exit', code => {
33
+ if (code === 0) resolve();
34
+ else reject(new Error(`Worker exited with code ${code}: ${stderr}`));
35
+ });
36
+ });
37
+ return { child, done };
38
+ }
39
+
40
+ function spawnWorker(args) {
41
+ return createWorker(args).done;
42
+ }
43
+
44
+ function spawnLockWorker(args) {
45
+ const worker = createWorker({ ...args, task: 'hold-lock' });
46
+ const locked = new Promise((resolve, reject) => {
47
+ worker.child.on('message', message => {
48
+ if (message?.type === 'locked') resolve({ done: worker.done });
49
+ });
50
+ worker.child.on('error', reject);
51
+ worker.child.on('exit', code => {
52
+ if (code !== 0) reject(new Error(`Lock worker exited before acquiring lock (${code})`));
53
+ });
54
+ });
55
+ return locked;
56
+ }
57
+
58
+ describe('SqliteDriver multi-process safety', function () {
59
+ this.timeout(30000);
60
+
61
+ beforeEach(function () {
62
+ fs.rmSync(testDataPath, { recursive: true, force: true });
63
+ fs.mkdirSync(testDataPath, { recursive: true });
64
+ });
65
+
66
+ afterEach(function () {
67
+ fs.rmSync(testDataPath, { recursive: true, force: true });
68
+ });
69
+
70
+ it('validates concurrency options eagerly', function () {
71
+ assert.throws(
72
+ () => new SqliteDriver({ pragma: 'invalid' }),
73
+ /pragma must be one of/,
74
+ );
75
+ assert.throws(
76
+ () => new SqliteDriver({ busyTimeoutMs: -1 }),
77
+ /busyTimeoutMs must be a non-negative integer/,
78
+ );
79
+ assert.throws(
80
+ () => new SqliteDriver({ busyRetry: { maxAttempts: 0 } }),
81
+ /maxAttempts must be at least 1/,
82
+ );
83
+ });
84
+
85
+ it('applies busyTimeoutMs to the SQLite connection', async function () {
86
+ const driver = new SqliteDriver({
87
+ name: 'configured-timeout',
88
+ path: testDataPath,
89
+ busyTimeoutMs: 123,
90
+ });
91
+ await driver.connect();
92
+ assert.strictEqual(driver.db.pragma('busy_timeout', { simple: true }), 123);
93
+ await driver.disconnect();
94
+ });
95
+
96
+ it('migrates a legacy database without seq and preserves observable order', async function () {
97
+ const name = 'legacy-no-seq';
98
+ const fileName = path.join(testDataPath, `${name}.db`);
99
+ createLegacyDatabase(fileName, [['b', 2], ['a', 1]]);
100
+
101
+ const driver = new SqliteDriver({ name, path: testDataPath });
102
+ await driver.connect();
103
+ await driver.disconnect();
104
+
105
+ const state = inspectDatabase(fileName);
106
+ assert.deepStrictEqual(state.rows, [
107
+ { key: 'a', seq: 1 },
108
+ { key: 'b', seq: 2 },
109
+ ]);
110
+ assert.strictEqual(state.schemaVersion, 1);
111
+ assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_unique' && index.unique === 1));
112
+ });
113
+
114
+ it('normalizes duplicate seq values without changing their current order', async function () {
115
+ const name = 'duplicate-seq';
116
+ const fileName = path.join(testDataPath, `${name}.db`);
117
+ createSequencedDatabase(fileName, [
118
+ ['c', 3, 2],
119
+ ['a', 1, 0],
120
+ ['d', 4, 2],
121
+ ['b', 2, 0],
122
+ ]);
123
+
124
+ const driver = new SqliteDriver({ name, path: testDataPath });
125
+ await driver.connect();
126
+ await driver.disconnect();
127
+
128
+ assert.deepStrictEqual(inspectDatabase(fileName).rows, [
129
+ { key: 'a', seq: 1 },
130
+ { key: 'b', seq: 2 },
131
+ { key: 'c', seq: 3 },
132
+ { key: 'd', seq: 4 },
133
+ ]);
134
+ });
135
+
136
+ it('rolls back the complete migration when normalization fails', async function () {
137
+ const name = 'migration-rollback';
138
+ const fileName = path.join(testDataPath, `${name}.db`);
139
+ createSequencedDatabase(fileName, [['a', 1, 0], ['b', 2, 0]], { failMigration: true });
140
+
141
+ const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
142
+ await assert.rejects(driver.connect(), /forced migration failure/);
143
+
144
+ const failedState = inspectDatabase(fileName);
145
+ assert.deepStrictEqual(failedState.rows, [
146
+ { key: 'a', seq: 0 },
147
+ { key: 'b', seq: 0 },
148
+ ]);
149
+ assert.strictEqual(failedState.schemaVersion, undefined);
150
+
151
+ const raw = new Database(fileName);
152
+ raw.exec('DROP TRIGGER fail_seq_migration');
153
+ raw.close();
154
+
155
+ await driver.connect();
156
+ await driver.disconnect();
157
+ assert.deepStrictEqual(inspectDatabase(fileName).rows, [
158
+ { key: 'a', seq: 1 },
159
+ { key: 'b', seq: 2 },
160
+ ]);
161
+ });
162
+
163
+ it('serializes concurrent schema migration across processes', async function () {
164
+ const name = 'concurrent-migration';
165
+ const fileName = path.join(testDataPath, `${name}.db`);
166
+ createLegacyDatabase(fileName, [['value', 1]]);
167
+
168
+ await Promise.all(Array.from({ length: 6 }, (_, workerId) =>
169
+ spawnWorker({
170
+ task: 'connect-only',
171
+ workerId,
172
+ name,
173
+ path: testDataPath,
174
+ ...retryOptions,
175
+ }),
176
+ ));
177
+
178
+ const state = inspectDatabase(fileName);
179
+ assert.strictEqual(state.schemaVersion, 1);
180
+ assert.deepStrictEqual(state.rows, [{ key: 'value', seq: 1 }]);
181
+ });
182
+
183
+ it('keeps increments atomic across processes', async function () {
184
+ const name = 'concurrent-inc';
185
+ const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
186
+ await driver.set('counter', 0);
187
+ await driver.disconnect();
188
+
189
+ await Promise.all(Array.from({ length: 4 }, (_, workerId) =>
190
+ spawnWorker({
191
+ task: 'inc',
192
+ workerId,
193
+ iterations: 25,
194
+ name,
195
+ path: testDataPath,
196
+ ...retryOptions,
197
+ }),
198
+ ));
199
+
200
+ const verify = new SqliteDriver({ name, path: testDataPath });
201
+ assert.strictEqual(await verify.get('counter'), 100);
202
+ await verify.disconnect();
203
+ });
204
+
205
+ it('preserves mixed writes from multiple processes with unique seq values', async function () {
206
+ const name = 'mixed-writes';
207
+ await Promise.all(Array.from({ length: 3 }, (_, workerId) =>
208
+ spawnWorker({
209
+ task: 'mixed',
210
+ workerId,
211
+ iterations: 10,
212
+ name,
213
+ path: testDataPath,
214
+ ...retryOptions,
215
+ }),
216
+ ));
217
+
218
+ const verify = new SqliteDriver({ name, path: testDataPath });
219
+ assert.strictEqual(Object.keys(await verify.get('entries')).length, 30);
220
+ assert.strictEqual(Object.keys(await verify.get('items')).length, 30);
221
+ assert.strictEqual(await verify.get('temporary'), null);
222
+ await verify.disconnect();
223
+
224
+ const rows = inspectDatabase(path.join(testDataPath, `${name}.db`)).rows;
225
+ assert.strictEqual(new Set(rows.map(row => row.seq)).size, rows.length);
226
+ });
227
+
228
+ it('retries a write until a short external lock is released', async function () {
229
+ const name = 'short-lock';
230
+ const fileName = path.join(testDataPath, `${name}.db`);
231
+ const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
232
+ await driver.connect();
233
+
234
+ const lockWorker = await spawnLockWorker({
235
+ fileName,
236
+ holdMs: 150,
237
+ busyTimeoutMs: retryOptions.busyTimeoutMs,
238
+ });
239
+ await driver.set('after-lock', true);
240
+ await lockWorker.done;
241
+
242
+ assert.strictEqual(await driver.get('after-lock'), true);
243
+ await driver.disconnect();
244
+ });
245
+
246
+ it('surfaces SQLITE_BUSY after the configured retry budget', async function () {
247
+ const name = 'long-lock';
248
+ const fileName = path.join(testDataPath, `${name}.db`);
249
+ const driver = new SqliteDriver({
250
+ name,
251
+ path: testDataPath,
252
+ busyTimeoutMs: 10,
253
+ busyRetry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
254
+ });
255
+ await driver.connect();
256
+
257
+ const lockWorker = await spawnLockWorker({ fileName, holdMs: 300, busyTimeoutMs: 10 });
258
+ await assert.rejects(
259
+ driver.set('blocked', true),
260
+ error => error.code === 'SQLITE_BUSY',
261
+ );
262
+ await lockWorker.done;
263
+ await driver.disconnect();
264
+ });
265
+ });
package/test/test.js CHANGED
@@ -353,11 +353,11 @@ for (const pragma of PRAGMA_MODES) {
353
353
  });
354
354
  });
355
355
 
356
- describe('Singleton Pattern', function () {
357
- it('should return same instance for same file', function () {
356
+ describe('Independent Connection Lifecycle', function () {
357
+ it('should create independent instances for the same file', function () {
358
358
  const d1 = new SqliteDriver({ name: `singleton-${pragma}`, path: testDataPath, pragma });
359
359
  const d2 = new SqliteDriver({ name: `singleton-${pragma}`, path: testDataPath, pragma });
360
- assert.strictEqual(d1, d2);
360
+ assert.notStrictEqual(d1, d2);
361
361
  });
362
362
 
363
363
  it('should return different instances for different files', function () {
@@ -365,6 +365,20 @@ for (const pragma of PRAGMA_MODES) {
365
365
  const d2 = new SqliteDriver({ name: `file2-${pragma}`, path: testDataPath, pragma });
366
366
  assert.notStrictEqual(d1, d2);
367
367
  });
368
+
369
+ it('disconnecting one instance should not close another connection', async function () {
370
+ const name = `lifecycle-${pragma}`;
371
+ const d1 = new SqliteDriver({ name, path: testDataPath, pragma });
372
+ const d2 = new SqliteDriver({ name, path: testDataPath, pragma });
373
+ await d1.connect();
374
+ await d2.connect();
375
+ await d1.set('first', 1);
376
+ await d1.disconnect();
377
+ await d2.set('second', 2);
378
+ assert.strictEqual(await d2.get('first'), 1);
379
+ assert.strictEqual(await d2.get('second'), 2);
380
+ await d2.disconnect();
381
+ });
368
382
  });
369
383
 
370
384
  describe('Root Object Operations', function () {
@@ -394,6 +408,37 @@ for (const pragma of PRAGMA_MODES) {
394
408
  assert.deepStrictEqual(await db.get(), { new: 'data' });
395
409
  assert.strictEqual(await db.get('old'), null);
396
410
  });
411
+
412
+ it('should read root when a null parent row coexists with child rows', async function () {
413
+ const itemPath = await db.add('user1', 'message', 'pending', { test: 1 });
414
+ const itemId = itemPath[itemPath.length - 1];
415
+ const driver = db.getDriver(0);
416
+ driver.db.prepare('INSERT OR REPLACE INTO deepbase (key, value, seq) VALUES (?, ?, ?)').run(
417
+ 'user1.message.pending',
418
+ 'null',
419
+ 999,
420
+ );
421
+
422
+ const pending = {
423
+ [itemId]: { test: 1 },
424
+ };
425
+ const message = {
426
+ pending,
427
+ };
428
+ const user = {
429
+ message,
430
+ };
431
+
432
+ assert.deepStrictEqual(await db.get(), {
433
+ user1: {
434
+ message,
435
+ },
436
+ });
437
+ assert.deepStrictEqual(await db.get('user1'), user);
438
+ assert.deepStrictEqual(await db.get('user1', 'message'), message);
439
+ assert.deepStrictEqual(await db.get('user1', 'message', 'pending'), pending);
440
+ assert.deepStrictEqual(await db.keys('user1', 'message', 'pending'), [itemId]);
441
+ });
397
442
  });
398
443
 
399
444
  describe('Deep Nesting', function () {
package/test/worker.js ADDED
@@ -0,0 +1,61 @@
1
+ import Database from 'better-sqlite3';
2
+ import { SqliteDriver } from '../src/SqliteDriver.js';
3
+
4
+ const options = JSON.parse(process.argv[2]);
5
+
6
+ function wait(ms) {
7
+ return new Promise(resolve => setTimeout(resolve, ms));
8
+ }
9
+
10
+ async function holdLock() {
11
+ const databaseOptions = options.busyTimeoutMs === undefined
12
+ ? undefined
13
+ : { timeout: options.busyTimeoutMs };
14
+ const db = new Database(options.fileName, databaseOptions);
15
+ db.exec('BEGIN IMMEDIATE');
16
+ process.send?.({ type: 'locked' });
17
+ await wait(options.holdMs);
18
+ db.exec('COMMIT');
19
+ db.close();
20
+ }
21
+
22
+ async function runDriverTask() {
23
+ const driver = new SqliteDriver({
24
+ name: options.name,
25
+ path: options.path,
26
+ pragma: options.pragma ?? 'balanced',
27
+ busyTimeoutMs: options.busyTimeoutMs,
28
+ busyRetry: options.busyRetry,
29
+ });
30
+
31
+ await driver.connect();
32
+
33
+ if (options.task === 'inc') {
34
+ for (let i = 0; i < options.iterations; i += 1) {
35
+ await driver.inc('counter', 1);
36
+ }
37
+ } else if (options.task === 'set-unique') {
38
+ for (let i = 0; i < options.iterations; i += 1) {
39
+ await driver.set('entries', `${options.workerId}-${i}`, i);
40
+ }
41
+ } else if (options.task === 'mixed') {
42
+ for (let i = 0; i < options.iterations; i += 1) {
43
+ await driver.set('entries', `${options.workerId}-${i}`, i);
44
+ await driver.add('items', { workerId: options.workerId, index: i });
45
+ await driver.set('temporary', `${options.workerId}-${i}`, true);
46
+ await driver.del('temporary', `${options.workerId}-${i}`);
47
+ }
48
+ } else if (options.task !== 'connect-only') {
49
+ throw new Error(`Unknown worker task: ${options.task}`);
50
+ }
51
+
52
+ await driver.disconnect();
53
+ }
54
+
55
+ if (options.task === 'hold-lock') {
56
+ await holdLock();
57
+ } else {
58
+ await runDriverTask();
59
+ }
60
+
61
+ process.disconnect?.();