deepbase-sqlite 3.6.10 → 3.8.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,58 @@ 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. Old and new sequence allocators must not write concurrently during a rolling deployment.
86
+
87
+ ### Maintenance
88
+
89
+ The driver owns the `better-sqlite3` connection and exposes maintenance through
90
+ its own API, so a backup or vacuum job needs no `better-sqlite3` entry in your
91
+ own `package.json`. That keeps a single native binding in the tree and removes
92
+ any chance of a version mismatch between your copy and the driver's.
93
+
94
+ ```javascript
95
+ const driver = db.getDriver(0);
96
+
97
+ const status = await driver.checkIntegrity(); // 'ok', or what SQLite found
98
+ await driver.backup('./backups/mydb-2026-07-28.db');
99
+ await driver.vacuum();
100
+ await driver.checkpoint('TRUNCATE');
101
+ ```
102
+
103
+ | Method | Behaviour |
104
+ |--------|-----------|
105
+ | `checkIntegrity()` | Runs `PRAGMA integrity_check` and returns its status verbatim. |
106
+ | `backup(destination)` | Online copy, verified before it resolves. Returns `destination`. |
107
+ | `vacuum()` | Rebuilds the file to reclaim free pages. |
108
+ | `checkpoint(mode)` | `PASSIVE` (default), `FULL`, `RESTART` or `TRUNCATE`. Returns `{ busy, log, checkpointed }`. |
109
+
110
+ `backup()` creates parent directories as needed, then reopens the result and
111
+ verifies it with `integrity_check` — a backup nobody validated is not a backup.
112
+ It rejects with code `DEEPBASE_SQLITE_BACKUP_CORRUPT` when the copy fails to
113
+ verify, leaving the bad file on disk for inspection, so a restore routine must
114
+ never pick a backup by timestamp alone. The copy is consistent with writes that
115
+ land while it runs, and it does not block the driver's write queue.
116
+
117
+ `vacuum()` and `checkpoint()` do take the write lock, so they queue behind the
118
+ driver's own writes and honour `busyRetry`. Run them when the application is
119
+ idle. `checkpoint()` rejects with code `DEEPBASE_SQLITE_NOT_WAL` on databases
120
+ opened with `pragma: 'none'`, which use a rollback journal and have no WAL.
121
+
122
+ Retention, rotation and scheduling stay in your application — the driver takes a
123
+ verified snapshot, nothing more.
124
+
77
125
  ### Nested Data Structure
78
126
 
79
127
  Efficiently stores nested objects using a key-value schema:
@@ -82,7 +130,7 @@ Efficiently stores nested objects using a key-value schema:
82
130
  - Values are stored as JSON
83
131
  - Fast lookups for both exact keys and partial paths
84
132
 
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`).
133
+ Each row also stores a 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`. For legacy databases, the driver only adds the missing column and index; it does not renumber existing rows. Historical ties remain deterministic through the `key` fallback order.
86
134
 
87
135
  ### ACID Compliance
88
136
 
@@ -90,7 +138,7 @@ SQLite provides:
90
138
 
91
139
  - **Atomicity**: All operations complete or none do
92
140
  - **Consistency**: Data remains valid across transactions
93
- - **Isolation**: Concurrent operations don't interfere
141
+ - **Isolation**: Concurrent writes are serialized by SQLite
94
142
  - **Durability**: Committed data persists even after crashes
95
143
 
96
144
  ## Pragma Modes
@@ -104,7 +152,7 @@ SQLite provides:
104
152
  | **balanced** *(default)* | NORMAL | 8 MB | 256 MB | Yes | Best mix of speed and safety for most apps |
105
153
  | **fast** | OFF | 16 MB | 256 MB | Yes | Maximum throughput — data may be lost on OS crash |
106
154
 
107
- All WAL modes use `journal_mode=WAL`, `temp_store=MEMORY`, and `busy_timeout=5000ms`.
155
+ 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
156
 
109
157
  ```javascript
110
158
  // Backward-compatible (no PRAGMAs, no WITHOUT ROWID)
@@ -132,30 +180,28 @@ import { SqliteFastDriver } from 'deepbase-sqlite';
132
180
  Data is stored in a simple key-value table:
133
181
 
134
182
  ```sql
135
- -- pragma: 'none' (legacy-compatible)
136
183
  CREATE TABLE deepbase (
137
184
  key TEXT PRIMARY KEY,
138
- value TEXT NOT NULL
139
- )
185
+ value TEXT NOT NULL,
186
+ seq INTEGER NOT NULL
187
+ );
140
188
 
141
- -- pragma: 'safe' | 'balanced' | 'fast' (optimized)
142
- CREATE TABLE deepbase (
143
- key TEXT PRIMARY KEY,
144
- value TEXT NOT NULL
145
- ) WITHOUT ROWID
189
+ CREATE INDEX deepbase_seq_idx ON deepbase(seq);
146
190
  ```
147
191
 
192
+ Optimized PRAGMA profiles create `deepbase` `WITHOUT ROWID`.
193
+
148
194
  Example data:
149
195
 
150
196
  ```
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"
197
+ key | value | seq
198
+ ----------------------|----------|----
199
+ users.alice.name | "Alice" | 1
200
+ users.alice.age | 30 | 2
201
+ users.bob.name | "Bob" | 3
202
+ users.bob.age | 25 | 4
203
+ config.theme | "dark" | 5
204
+ config.lang | "en" | 6
159
205
  ```
160
206
 
161
207
  ## Use Cases
@@ -271,6 +317,17 @@ await db.set(`user_${userId}_profile`, data);
271
317
 
272
318
  ## Troubleshooting
273
319
 
320
+ ### `SQLITE_BUSY` / `database is locked`
321
+
322
+ The driver waits and retries complete transactions when another connection owns the write lock. If the retry budget is exhausted:
323
+
324
+ 1. Confirm every process points to the same local filesystem, not NFS.
325
+ 2. Look for long-running migrations, raw `better-sqlite3` connections, SQLite tools, or overlapping deployments.
326
+ 3. Increase `busyTimeoutMs` or `busyRetry.maxAttempts` only for known transient contention.
327
+ 4. Move to a client-server database if write contention is sustained.
328
+
329
+ `better-sqlite3` is synchronous. Each native lock wait blocks that Node.js thread for up to `busyTimeoutMs`; the JavaScript backoff between attempts is asynchronous.
330
+
274
331
  ### `Could not locate the bindings file` / `better_sqlite3.node` missing
275
332
 
276
333
  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.10",
3
+ "version": "3.8.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.10"
21
- },
22
- "scripts": {
23
- "test": "mocha test/test.js"
20
+ "deepbase": "^3.8.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,29 @@ 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 { backupTo, checkIntegrity, checkpoint, vacuum } from './maintenance.js';
8
+ import { ensureSchema } from './schema.js';
33
9
 
34
10
  export class SqliteDriver extends DeepBaseDriver {
35
- static _instances = {};
36
-
37
- constructor({ name, path, pragma, ...opts } = {}) {
11
+ constructor({ name, path, pragma, busyTimeoutMs, busyRetry, ...opts } = {}) {
38
12
  super(opts);
39
13
 
14
+ const config = resolveSqliteConfig({ pragma, busyTimeoutMs, busyRetry });
40
15
  this.name = name || 'default';
41
16
  this.path = path || pathModule.join(process.cwd(), 'db');
42
- this.pragma = pragma || 'balanced';
17
+ this.pragma = config.pragma;
18
+ this.pragmaConfig = config.pragmaConfig;
19
+ this.busyTimeoutMs = config.busyTimeoutMs;
20
+ this.busyRetry = config.busyRetry;
43
21
 
44
22
  this.path = pathModule.resolve(this.path);
45
23
  this.fileName = pathModule.join(this.path, `${this.name}.db`);
46
24
 
47
- if (SqliteDriver._instances[this.fileName]) {
48
- return SqliteDriver._instances[this.fileName];
49
- }
50
-
51
25
  this.db = null;
52
- this._nextSeq = 1;
53
- SqliteDriver._instances[this.fileName] = this;
26
+ this._connectPromise = null;
27
+ this._writeQueue = Promise.resolve();
54
28
  }
55
29
 
56
30
  _connectSync() {
@@ -61,7 +35,7 @@ export class SqliteDriver extends DeepBaseDriver {
61
35
  }
62
36
 
63
37
  try {
64
- this.db = new Database(this.fileName);
38
+ this.db = new Database(this.fileName, { timeout: this.busyTimeoutMs });
65
39
  } catch (err) {
66
40
  if (this._isMissingNativeBinding(err)) {
67
41
  const hint =
@@ -78,35 +52,22 @@ export class SqliteDriver extends DeepBaseDriver {
78
52
  throw err;
79
53
  }
80
54
 
81
- const cfg = PRAGMA[this.pragma];
55
+ const cfg = this.pragmaConfig;
82
56
  if (cfg) {
83
57
  this.db.pragma(`journal_mode = ${cfg.journal_mode}`);
84
58
  this.db.pragma(`synchronous = ${cfg.synchronous}`);
85
59
  this.db.pragma(`temp_store = ${cfg.temp_store}`);
86
60
  this.db.pragma(`cache_size = ${cfg.cache_size}`);
87
- this.db.pragma(`busy_timeout = ${cfg.busy_timeout}`);
88
61
  this.db.pragma(`mmap_size = ${cfg.mmap_size}`);
89
62
  }
90
63
 
91
64
  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
- }
65
+ ensureSchema(this.db, { withoutRowid });
104
66
 
105
67
  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
68
  this.setStmt = this.db.prepare(`
108
69
  INSERT INTO deepbase (key, value, seq)
109
- VALUES (?, ?, ?)
70
+ VALUES (?, ?, (SELECT IFNULL(MAX(seq), 0) + 1 FROM deepbase))
110
71
  ON CONFLICT(key) DO UPDATE SET value = excluded.value
111
72
  `);
112
73
  this.delStmt = this.db.prepare('DELETE FROM deepbase WHERE key = ?');
@@ -123,18 +84,20 @@ export class SqliteDriver extends DeepBaseDriver {
123
84
  this.delChildrenStmt = this.db.prepare("DELETE FROM deepbase WHERE key LIKE ? ESCAPE '!'");
124
85
  this.hasChildrenStmt = this.db.prepare("SELECT 1 FROM deepbase WHERE key LIKE ? ESCAPE '!' LIMIT 1");
125
86
 
126
- this._setTxn = this.db.transaction((key, jsonValue, keys) => {
87
+ const setTxn = this.db.transaction((key, jsonValue, keys) => {
127
88
  this._expandParentObjects(keys);
128
89
  this._replaceRow(key, jsonValue);
129
90
  });
91
+ this._setTxn = (...args) => setTxn.immediate(...args);
130
92
 
131
- this._delTxn = this.db.transaction((key, likePattern, keys) => {
93
+ const delTxn = this.db.transaction((key, likePattern, keys) => {
132
94
  this._expandParentObjects(keys);
133
95
  this.delStmt.run(key);
134
96
  this.delChildrenStmt.run(likePattern);
135
97
  });
98
+ this._delTxn = (...args) => delTxn.immediate(...args);
136
99
 
137
- this._updTxn = this.db.transaction((keys, func) => {
100
+ const updTxn = this.db.transaction((keys, func) => {
138
101
  const currentValue = this._getSync(keys);
139
102
  const newValue = func(currentValue);
140
103
  const key = this._pathToKey(keys);
@@ -142,37 +105,102 @@ export class SqliteDriver extends DeepBaseDriver {
142
105
  this._replaceRow(key, JSON.stringify(newValue));
143
106
  return keys;
144
107
  });
108
+ this._updTxn = (...args) => updTxn.immediate(...args);
145
109
 
146
- this._setRootTxn = this.db.transaction((entries) => {
110
+ const setRootTxn = this.db.transaction((entries) => {
147
111
  this.db.exec('DELETE FROM deepbase');
148
- this._nextSeq = 1;
149
112
  for (const [key, value] of entries) {
150
- this.setStmt.run(key, JSON.stringify(value), this._consumeSeq());
113
+ this.setStmt.run(key, JSON.stringify(value));
151
114
  }
152
115
  });
116
+ this._setRootTxn = (...args) => setRootTxn.immediate(...args);
117
+
118
+ const clearTxn = this.db.transaction(() => {
119
+ this.db.exec('DELETE FROM deepbase');
120
+ });
121
+ this._clearTxn = (...args) => clearTxn.immediate(...args);
153
122
 
154
- this._nextSeq = Number(this.getMaxSeqStmt.get()?.maxSeq || 0) + 1;
155
123
  this._connected = true;
156
124
  }
157
125
 
126
+ async checkIntegrity() {
127
+ await this.connect();
128
+ return checkIntegrity(this.db);
129
+ }
130
+
131
+ async backup(destination) {
132
+ await this.connect();
133
+ return backupTo(this.db, destination);
134
+ }
135
+
136
+ async vacuum() {
137
+ return this._runWrite(() => vacuum(this.db));
138
+ }
139
+
140
+ async checkpoint(mode = 'PASSIVE') {
141
+ return this._runWrite(() => checkpoint(this.db, mode));
142
+ }
143
+
158
144
  async connect() {
159
- this._connectSync();
145
+ if (this._connected) return;
146
+ if (!this._connectPromise) {
147
+ this._connectPromise = withBusyRetry(() => this._openSync(), this.busyRetry)
148
+ .finally(() => {
149
+ this._connectPromise = null;
150
+ });
151
+ }
152
+ return this._connectPromise;
153
+ }
154
+
155
+ _openSync() {
156
+ try {
157
+ this._connectSync();
158
+ } catch (error) {
159
+ this._closeConnection();
160
+ throw error;
161
+ }
160
162
  }
161
163
 
162
164
  async disconnect() {
165
+ return this._queueWrite(async () => {
166
+ if (this._connectPromise) {
167
+ await this._connectPromise;
168
+ }
169
+ this._closeConnection();
170
+ });
171
+ }
172
+
173
+ _closeConnection() {
163
174
  if (this.db) {
164
175
  this.db.close();
165
- this.db = null;
166
176
  }
177
+ this.db = null;
167
178
  this._connected = false;
168
179
  }
169
180
 
181
+ _queueWrite(operation) {
182
+ const result = this._writeQueue.then(operation, operation);
183
+ this._writeQueue = result.then(
184
+ () => undefined,
185
+ () => undefined,
186
+ );
187
+ return result;
188
+ }
189
+
190
+ _runWrite(operation) {
191
+ return this._queueWrite(async () => {
192
+ await this.connect();
193
+ return withBusyRetry(operation, this.busyRetry);
194
+ });
195
+ }
196
+
170
197
  async get(...args) {
198
+ await this.connect();
171
199
  return this._getSync(args);
172
200
  }
173
201
 
174
202
  getSync(...args) {
175
- this._connectSync();
203
+ this._openSync();
176
204
  return this._getSync(args);
177
205
  }
178
206
 
@@ -205,35 +233,40 @@ export class SqliteDriver extends DeepBaseDriver {
205
233
  }
206
234
 
207
235
  if (args.length === 1) {
208
- await this._setRootObject(args[0]);
209
- return [];
236
+ const entries = this._flattenObject(args[0]);
237
+ return this._runWrite(() => {
238
+ this._setRootTxn(entries);
239
+ return [];
240
+ });
210
241
  }
211
242
 
212
- return this._setSync(args);
213
- }
214
-
215
- _setSync(args) {
216
243
  const keys = args.slice(0, -1);
217
244
  const value = args[args.length - 1];
218
245
  const key = this._pathToKey(keys);
219
- this._setTxn(key, JSON.stringify(value), keys);
220
- return keys;
246
+ const jsonValue = JSON.stringify(value);
247
+ return this._runWrite(() => {
248
+ this._setTxn(key, jsonValue, keys);
249
+ return keys;
250
+ });
221
251
  }
222
252
 
223
253
  _replaceRow(key, jsonValue) {
224
254
  this.delChildrenStmt.run(this._likePrefix(key));
225
- this.setStmt.run(key, jsonValue, this._consumeSeq());
255
+ this.setStmt.run(key, jsonValue);
226
256
  }
227
257
 
228
258
  async del(...keys) {
229
259
  if (keys.length === 0) {
230
- this.db.exec('DELETE FROM deepbase');
231
- this._nextSeq = 1;
232
- return;
260
+ return this._runWrite(() => {
261
+ this._clearTxn();
262
+ });
233
263
  }
234
264
 
235
265
  const key = this._pathToKey(keys);
236
- this._delTxn(key, this._likePrefix(key), keys);
266
+ const likePattern = this._likePrefix(key);
267
+ return this._runWrite(() => {
268
+ this._delTxn(key, likePattern, keys);
269
+ });
237
270
  }
238
271
 
239
272
  async inc(...args) {
@@ -256,14 +289,16 @@ export class SqliteDriver extends DeepBaseDriver {
256
289
  async upd(...args) {
257
290
  const func = args.pop();
258
291
  const keys = args;
259
- return this._updTxn(keys, func);
292
+ return this._runWrite(() => this._updTxn(keys, func));
260
293
  }
261
294
 
262
295
  async first(...args) {
296
+ await this.connect();
263
297
  return this._firstOrLastKey(args, false);
264
298
  }
265
299
 
266
300
  async last(...args) {
301
+ await this.connect();
267
302
  return this._firstOrLastKey(args, true);
268
303
  }
269
304
 
@@ -310,11 +345,6 @@ export class SqliteDriver extends DeepBaseDriver {
310
345
  return result;
311
346
  }
312
347
 
313
- async _setRootObject(obj) {
314
- const entries = this._flattenObject(obj);
315
- this._setRootTxn(entries);
316
- }
317
-
318
348
  _buildObjectFromChildren(parentKey, likePattern) {
319
349
  const rows = this.getKeysLikeStmt.all(likePattern || (parentKey ? this._likePrefix(parentKey) : '%'));
320
350
  const result = {};
@@ -434,18 +464,12 @@ export class SqliteDriver extends DeepBaseDriver {
434
464
 
435
465
  const entries = this._flattenObject(parentValue, parentKey);
436
466
  for (const [key, value] of entries) {
437
- this.setStmt.run(key, JSON.stringify(value), this._consumeSeq());
467
+ this.setStmt.run(key, JSON.stringify(value));
438
468
  }
439
469
  }
440
470
  }
441
471
  }
442
472
  }
443
-
444
- _consumeSeq() {
445
- const seq = this._nextSeq;
446
- this._nextSeq += 1;
447
- return seq;
448
- }
449
473
  }
450
474
 
451
475
  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,77 @@
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
+ busyTimeoutMs: 5000,
29
+ busyRetry: Object.freeze({
30
+ maxAttempts: 2,
31
+ baseDelayMs: 25,
32
+ maxDelayMs: 250,
33
+ }),
34
+ pragmaProfiles: PRAGMA_PROFILES,
35
+ });
36
+
37
+ function assertNonNegativeInteger(name, value) {
38
+ if (!Number.isInteger(value) || value < 0) {
39
+ throw new TypeError(`${name} must be a non-negative integer`);
40
+ }
41
+ }
42
+
43
+ export function resolveSqliteConfig({ pragma, busyTimeoutMs, busyRetry } = {}) {
44
+ const resolvedPragma = pragma ?? SQLITE_CONFIG.defaultPragma;
45
+ if (!Object.prototype.hasOwnProperty.call(SQLITE_CONFIG.pragmaProfiles, resolvedPragma)) {
46
+ throw new TypeError(`pragma must be one of: ${Object.keys(SQLITE_CONFIG.pragmaProfiles).join(', ')}`);
47
+ }
48
+
49
+ const resolvedBusyTimeoutMs = busyTimeoutMs ?? SQLITE_CONFIG.busyTimeoutMs;
50
+ assertNonNegativeInteger('busyTimeoutMs', resolvedBusyTimeoutMs);
51
+
52
+ if (busyRetry !== undefined && (busyRetry === null || typeof busyRetry !== 'object' || Array.isArray(busyRetry))) {
53
+ throw new TypeError('busyRetry must be an object');
54
+ }
55
+
56
+ const resolvedBusyRetry = {
57
+ ...SQLITE_CONFIG.busyRetry,
58
+ ...(busyRetry ?? {}),
59
+ };
60
+ assertNonNegativeInteger('busyRetry.maxAttempts', resolvedBusyRetry.maxAttempts);
61
+ assertNonNegativeInteger('busyRetry.baseDelayMs', resolvedBusyRetry.baseDelayMs);
62
+ assertNonNegativeInteger('busyRetry.maxDelayMs', resolvedBusyRetry.maxDelayMs);
63
+
64
+ if (resolvedBusyRetry.maxAttempts < 1) {
65
+ throw new TypeError('busyRetry.maxAttempts must be at least 1');
66
+ }
67
+ if (resolvedBusyRetry.maxDelayMs < resolvedBusyRetry.baseDelayMs) {
68
+ throw new TypeError('busyRetry.maxDelayMs must be greater than or equal to busyRetry.baseDelayMs');
69
+ }
70
+
71
+ return {
72
+ pragma: resolvedPragma,
73
+ pragmaConfig: SQLITE_CONFIG.pragmaProfiles[resolvedPragma],
74
+ busyTimeoutMs: resolvedBusyTimeoutMs,
75
+ busyRetry: resolvedBusyRetry,
76
+ };
77
+ }
package/src/index.d.ts CHANGED
@@ -1,9 +1,28 @@
1
1
  import { DeepBaseDriver, DeepBaseDriverOptions } from 'deepbase';
2
2
 
3
+ export interface SqliteBusyRetryOptions {
4
+ maxAttempts?: number;
5
+ baseDelayMs?: number;
6
+ maxDelayMs?: number;
7
+ }
8
+
9
+ export type SqliteCheckpointMode = 'PASSIVE' | 'FULL' | 'RESTART' | 'TRUNCATE';
10
+
11
+ export interface SqliteCheckpointResult {
12
+ /** `1` when readers or writers prevented the checkpoint from completing. */
13
+ busy: number;
14
+ /** Pages in the WAL file. */
15
+ log: number;
16
+ /** Pages moved into the database file. */
17
+ checkpointed: number;
18
+ }
19
+
3
20
  export interface SqliteDriverOptions extends DeepBaseDriverOptions {
4
21
  name?: string;
5
22
  path?: string;
6
23
  pragma?: 'none' | 'safe' | 'balanced' | 'fast';
24
+ busyTimeoutMs?: number;
25
+ busyRetry?: SqliteBusyRetryOptions;
7
26
  }
8
27
 
9
28
  export class SqliteDriver extends DeepBaseDriver {
@@ -13,6 +32,29 @@ export class SqliteDriver extends DeepBaseDriver {
13
32
  path: string;
14
33
  fileName: string;
15
34
  pragma: string;
35
+ busyTimeoutMs: number;
36
+ busyRetry: Required<SqliteBusyRetryOptions>;
37
+
38
+ /** Runs `PRAGMA integrity_check` and returns its status: `'ok'` when the
39
+ * database is sound, otherwise SQLite's description of the damage. */
40
+ checkIntegrity(): Promise<string>;
41
+
42
+ /**
43
+ * Writes an online backup to `destination`, creating parent directories as
44
+ * needed, then verifies the copy with `integrity_check`. Resolves with
45
+ * `destination`, or rejects with code `DEEPBASE_SQLITE_BACKUP_CORRUPT` when
46
+ * the copy does not verify.
47
+ */
48
+ backup(destination: string): Promise<string>;
49
+
50
+ /** Rebuilds the database file to reclaim free pages. Takes the write lock. */
51
+ vacuum(): Promise<void>;
52
+
53
+ /**
54
+ * Runs `PRAGMA wal_checkpoint`. Rejects with code `DEEPBASE_SQLITE_NOT_WAL`
55
+ * when the database uses a rollback journal (`pragma: 'none'`).
56
+ */
57
+ checkpoint(mode?: SqliteCheckpointMode): Promise<SqliteCheckpointResult>;
16
58
  }
17
59
 
18
60
  export { SqliteDriver as SqliteFastDriver };
@@ -0,0 +1,58 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import * as pathModule from 'path';
4
+
5
+ const CHECKPOINT_MODES = ['PASSIVE', 'FULL', 'RESTART', 'TRUNCATE'];
6
+
7
+ export function checkIntegrity(db) {
8
+ return db.pragma('integrity_check', { simple: true });
9
+ }
10
+
11
+ export async function backupTo(db, destination) {
12
+ fs.mkdirSync(pathModule.dirname(pathModule.resolve(destination)), { recursive: true });
13
+
14
+ await db.backup(destination);
15
+
16
+ const copy = new Database(destination, { readonly: true });
17
+ let status;
18
+ try {
19
+ status = checkIntegrity(copy);
20
+ } finally {
21
+ copy.close();
22
+ }
23
+
24
+ if (status !== 'ok') {
25
+ const error = new Error(
26
+ `deepbase-sqlite: backup verification failed for ${destination}: ${status}. ` +
27
+ 'The corrupt file was left in place for inspection; do not restore from it.',
28
+ );
29
+ error.code = 'DEEPBASE_SQLITE_BACKUP_CORRUPT';
30
+ throw error;
31
+ }
32
+
33
+ return destination;
34
+ }
35
+
36
+ export function vacuum(db) {
37
+ db.exec('VACUUM');
38
+ }
39
+
40
+ export function checkpoint(db, mode) {
41
+ if (!CHECKPOINT_MODES.includes(mode)) {
42
+ throw new TypeError(
43
+ `deepbase-sqlite: checkpoint mode must be one of ${CHECKPOINT_MODES.join(', ')}; received ${mode}`,
44
+ );
45
+ }
46
+
47
+ if (db.pragma('journal_mode', { simple: true }) !== 'wal') {
48
+ const error = new Error(
49
+ "deepbase-sqlite: checkpoint requires journal_mode=WAL. Databases opened with pragma: 'none' " +
50
+ 'use a rollback journal and have no WAL to checkpoint.',
51
+ );
52
+ error.code = 'DEEPBASE_SQLITE_NOT_WAL';
53
+ throw error;
54
+ }
55
+
56
+ const [result] = db.pragma(`wal_checkpoint(${mode})`);
57
+ return result;
58
+ }
package/src/schema.js ADDED
@@ -0,0 +1,20 @@
1
+ export function ensureSchema(db, { withoutRowid = '' } = {}) {
2
+ const setup = db.transaction(() => {
3
+ db.exec(`
4
+ CREATE TABLE IF NOT EXISTS deepbase (
5
+ key TEXT PRIMARY KEY,
6
+ value TEXT NOT NULL,
7
+ seq INTEGER NOT NULL DEFAULT 0
8
+ )${withoutRowid}
9
+ `);
10
+
11
+ const columns = db.prepare('PRAGMA table_info(deepbase)').all();
12
+ if (!columns.some(column => column.name === 'seq')) {
13
+ db.exec('ALTER TABLE deepbase ADD COLUMN seq INTEGER NOT NULL DEFAULT 0');
14
+ }
15
+
16
+ db.exec('CREATE INDEX IF NOT EXISTS deepbase_seq_idx ON deepbase(seq)');
17
+ });
18
+
19
+ setup.immediate();
20
+ }
@@ -0,0 +1,41 @@
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) {
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
+ db.close();
33
+ }
34
+
35
+ export function inspectDatabase(fileName) {
36
+ const db = new Database(fileName);
37
+ const rows = db.prepare('SELECT key, seq FROM deepbase ORDER BY seq, key').all();
38
+ const indexes = db.prepare("PRAGMA index_list('deepbase')").all();
39
+ db.close();
40
+ return { rows, indexes };
41
+ }
@@ -0,0 +1,236 @@
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 { SqliteDriver } from '../src/SqliteDriver.js';
7
+ import { createLegacyDatabase, createSequencedDatabase, inspectDatabase } from './fixtures.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const workerPath = path.join(__dirname, 'worker.js');
11
+ const testDataPath = path.join(__dirname, 'test-data-multiprocess');
12
+ const retryOptions = {
13
+ busyTimeoutMs: 50,
14
+ busyRetry: {
15
+ maxAttempts: 20,
16
+ baseDelayMs: 2,
17
+ maxDelayMs: 20,
18
+ },
19
+ };
20
+
21
+ function createWorker(args) {
22
+ const child = fork(workerPath, [JSON.stringify(args)], {
23
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
24
+ });
25
+ let stderr = '';
26
+ child.stderr.on('data', data => {
27
+ stderr += data.toString();
28
+ });
29
+ const done = new Promise((resolve, reject) => {
30
+ child.on('error', reject);
31
+ child.on('exit', code => {
32
+ if (code === 0) resolve();
33
+ else reject(new Error(`Worker exited with code ${code}: ${stderr}`));
34
+ });
35
+ });
36
+ return { child, done };
37
+ }
38
+
39
+ function spawnWorker(args) {
40
+ return createWorker(args).done;
41
+ }
42
+
43
+ function spawnLockWorker(args) {
44
+ const worker = createWorker({ ...args, task: 'hold-lock' });
45
+ const locked = new Promise((resolve, reject) => {
46
+ worker.child.on('message', message => {
47
+ if (message?.type === 'locked') resolve({ done: worker.done });
48
+ });
49
+ worker.child.on('error', reject);
50
+ worker.child.on('exit', code => {
51
+ if (code !== 0) reject(new Error(`Lock worker exited before acquiring lock (${code})`));
52
+ });
53
+ });
54
+ return locked;
55
+ }
56
+
57
+ describe('SqliteDriver multi-process safety', function () {
58
+ this.timeout(30000);
59
+
60
+ beforeEach(function () {
61
+ fs.rmSync(testDataPath, { recursive: true, force: true });
62
+ fs.mkdirSync(testDataPath, { recursive: true });
63
+ });
64
+
65
+ afterEach(function () {
66
+ fs.rmSync(testDataPath, { recursive: true, force: true });
67
+ });
68
+
69
+ it('validates concurrency options eagerly', function () {
70
+ assert.throws(
71
+ () => new SqliteDriver({ pragma: 'invalid' }),
72
+ /pragma must be one of/,
73
+ );
74
+ assert.throws(
75
+ () => new SqliteDriver({ busyTimeoutMs: -1 }),
76
+ /busyTimeoutMs must be a non-negative integer/,
77
+ );
78
+ assert.throws(
79
+ () => new SqliteDriver({ busyRetry: { maxAttempts: 0 } }),
80
+ /maxAttempts must be at least 1/,
81
+ );
82
+ });
83
+
84
+ it('applies busyTimeoutMs to the SQLite connection', async function () {
85
+ const driver = new SqliteDriver({
86
+ name: 'configured-timeout',
87
+ path: testDataPath,
88
+ busyTimeoutMs: 123,
89
+ });
90
+ await driver.connect();
91
+ assert.strictEqual(driver.db.pragma('busy_timeout', { simple: true }), 123);
92
+ await driver.disconnect();
93
+ });
94
+
95
+ it('adds a missing seq column without rewriting legacy rows', async function () {
96
+ const name = 'legacy-no-seq';
97
+ const fileName = path.join(testDataPath, `${name}.db`);
98
+ createLegacyDatabase(fileName, [['b', 2], ['a', 1]]);
99
+
100
+ const driver = new SqliteDriver({ name, path: testDataPath });
101
+ await driver.connect();
102
+ await driver.disconnect();
103
+
104
+ const state = inspectDatabase(fileName);
105
+ assert.deepStrictEqual(state.rows, [
106
+ { key: 'a', seq: 0 },
107
+ { key: 'b', seq: 0 },
108
+ ]);
109
+ assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_idx' && index.unique === 0));
110
+ });
111
+
112
+ it('preserves duplicate historical seq values and their stable key order', async function () {
113
+ const name = 'duplicate-seq';
114
+ const fileName = path.join(testDataPath, `${name}.db`);
115
+ createSequencedDatabase(fileName, [
116
+ ['c', 3, 2],
117
+ ['a', 1, 0],
118
+ ['d', 4, 2],
119
+ ['b', 2, 0],
120
+ ]);
121
+
122
+ const driver = new SqliteDriver({ name, path: testDataPath });
123
+ await driver.connect();
124
+ await driver.disconnect();
125
+
126
+ assert.deepStrictEqual(inspectDatabase(fileName).rows, [
127
+ { key: 'a', seq: 0 },
128
+ { key: 'b', seq: 0 },
129
+ { key: 'c', seq: 2 },
130
+ { key: 'd', seq: 2 },
131
+ ]);
132
+ });
133
+
134
+ it('serializes concurrent schema setup across processes', async function () {
135
+ const name = 'concurrent-migration';
136
+ const fileName = path.join(testDataPath, `${name}.db`);
137
+ createLegacyDatabase(fileName, [['value', 1]]);
138
+
139
+ await Promise.all(Array.from({ length: 6 }, (_, workerId) =>
140
+ spawnWorker({
141
+ task: 'connect-only',
142
+ workerId,
143
+ name,
144
+ path: testDataPath,
145
+ ...retryOptions,
146
+ }),
147
+ ));
148
+
149
+ const state = inspectDatabase(fileName);
150
+ assert.deepStrictEqual(state.rows, [{ key: 'value', seq: 0 }]);
151
+ assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_idx'));
152
+ });
153
+
154
+ it('keeps increments atomic across processes', async function () {
155
+ const name = 'concurrent-inc';
156
+ const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
157
+ await driver.set('counter', 0);
158
+ await driver.disconnect();
159
+
160
+ await Promise.all(Array.from({ length: 4 }, (_, workerId) =>
161
+ spawnWorker({
162
+ task: 'inc',
163
+ workerId,
164
+ iterations: 25,
165
+ name,
166
+ path: testDataPath,
167
+ ...retryOptions,
168
+ }),
169
+ ));
170
+
171
+ const verify = new SqliteDriver({ name, path: testDataPath });
172
+ assert.strictEqual(await verify.get('counter'), 100);
173
+ await verify.disconnect();
174
+ });
175
+
176
+ it('preserves mixed writes from multiple processes with unique seq values', async function () {
177
+ const name = 'mixed-writes';
178
+ await Promise.all(Array.from({ length: 3 }, (_, workerId) =>
179
+ spawnWorker({
180
+ task: 'mixed',
181
+ workerId,
182
+ iterations: 10,
183
+ name,
184
+ path: testDataPath,
185
+ ...retryOptions,
186
+ }),
187
+ ));
188
+
189
+ const verify = new SqliteDriver({ name, path: testDataPath });
190
+ assert.strictEqual(Object.keys(await verify.get('entries')).length, 30);
191
+ assert.strictEqual(Object.keys(await verify.get('items')).length, 30);
192
+ assert.strictEqual(await verify.get('temporary'), null);
193
+ await verify.disconnect();
194
+
195
+ const rows = inspectDatabase(path.join(testDataPath, `${name}.db`)).rows;
196
+ assert.strictEqual(new Set(rows.map(row => row.seq)).size, rows.length);
197
+ });
198
+
199
+ it('retries a write until a short external lock is released', async function () {
200
+ const name = 'short-lock';
201
+ const fileName = path.join(testDataPath, `${name}.db`);
202
+ const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
203
+ await driver.connect();
204
+
205
+ const lockWorker = await spawnLockWorker({
206
+ fileName,
207
+ holdMs: 150,
208
+ busyTimeoutMs: retryOptions.busyTimeoutMs,
209
+ });
210
+ await driver.set('after-lock', true);
211
+ await lockWorker.done;
212
+
213
+ assert.strictEqual(await driver.get('after-lock'), true);
214
+ await driver.disconnect();
215
+ });
216
+
217
+ it('surfaces SQLITE_BUSY after the configured retry budget', async function () {
218
+ const name = 'long-lock';
219
+ const fileName = path.join(testDataPath, `${name}.db`);
220
+ const driver = new SqliteDriver({
221
+ name,
222
+ path: testDataPath,
223
+ busyTimeoutMs: 10,
224
+ busyRetry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 },
225
+ });
226
+ await driver.connect();
227
+
228
+ const lockWorker = await spawnLockWorker({ fileName, holdMs: 300, busyTimeoutMs: 10 });
229
+ await assert.rejects(
230
+ driver.set('blocked', true),
231
+ error => error.code === 'SQLITE_BUSY',
232
+ );
233
+ await lockWorker.done;
234
+ await driver.disconnect();
235
+ });
236
+ });
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 () {
@@ -714,5 +728,127 @@ for (const pragma of PRAGMA_MODES) {
714
728
  assert.strictEqual(await db.get('inventory'), 500);
715
729
  });
716
730
  });
731
+
732
+ describe('Maintenance', function () {
733
+ it('reports a healthy database as ok', async function () {
734
+ await db.set('key', 'value');
735
+ assert.strictEqual(await db.getDriver(0).checkIntegrity(), 'ok');
736
+ });
737
+
738
+ it('backs up to a verified copy containing the data', async function () {
739
+ await db.set('users', 'alice', { name: 'Alice' });
740
+ const destination = path.join(testDataPath, 'backups', `backup-${pragma}.db`);
741
+
742
+ assert.strictEqual(await db.getDriver(0).backup(destination), destination);
743
+
744
+ const copy = new DeepBase(new SqliteDriver({
745
+ name: `backup-${pragma}`,
746
+ path: path.join(testDataPath, 'backups'),
747
+ pragma,
748
+ }));
749
+ assert.deepStrictEqual(await copy.get('users', 'alice'), { name: 'Alice' });
750
+ await copy.disconnect();
751
+ });
752
+
753
+ it('creates missing parent directories for the destination', async function () {
754
+ const destination = path.join(testDataPath, 'deep', 'nested', 'backup.db');
755
+ await db.getDriver(0).backup(destination);
756
+ assert.ok(fs.existsSync(destination));
757
+ });
758
+
759
+ it('includes writes made after the driver opened the connection', async function () {
760
+ await db.set('counter', 41);
761
+ await db.inc('counter', 1);
762
+ const destination = path.join(testDataPath, 'backups', `late-${pragma}.db`);
763
+ await db.getDriver(0).backup(destination);
764
+
765
+ const copy = new DeepBase(new SqliteDriver({
766
+ name: `late-${pragma}`,
767
+ path: path.join(testDataPath, 'backups'),
768
+ pragma,
769
+ }));
770
+ assert.strictEqual(await copy.get('counter'), 42);
771
+ await copy.disconnect();
772
+ });
773
+
774
+ it('opens the connection on demand', async function () {
775
+ const driver = new SqliteDriver({ name: `lazy-${pragma}`, path: testDataPath, pragma });
776
+ assert.strictEqual(await driver.checkIntegrity(), 'ok');
777
+ await driver.disconnect();
778
+ });
779
+
780
+ it('vacuums without losing data', async function () {
781
+ for (let i = 0; i < 50; i++) {
782
+ await db.set('users', `user${i}`, { name: `User ${i}` });
783
+ }
784
+ await db.del('users', 'user0');
785
+
786
+ await db.getDriver(0).vacuum();
787
+
788
+ assert.strictEqual(await db.get('users', 'user0'), null);
789
+ assert.deepStrictEqual(await db.get('users', 'user49'), { name: 'User 49' });
790
+ assert.strictEqual(await db.getDriver(0).checkIntegrity(), 'ok');
791
+ });
792
+
793
+ it('rejects an unknown checkpoint mode', async function () {
794
+ await assert.rejects(db.getDriver(0).checkpoint('SOMETIMES'), TypeError);
795
+ });
796
+
797
+ if (pragma === 'none') {
798
+ it('refuses to checkpoint a rollback-journal database', async function () {
799
+ await db.set('key', 'value');
800
+ await assert.rejects(
801
+ db.getDriver(0).checkpoint(),
802
+ error => error.code === 'DEEPBASE_SQLITE_NOT_WAL',
803
+ );
804
+ });
805
+ } else {
806
+ it('checkpoints the WAL back into the database file', async function () {
807
+ await db.set('key', 'value');
808
+ const result = await db.getDriver(0).checkpoint('TRUNCATE');
809
+ assert.strictEqual(result.busy, 0);
810
+ assert.strictEqual(result.log, 0, 'TRUNCATE leaves an empty WAL');
811
+ assert.strictEqual(await db.get('key'), 'value');
812
+ });
813
+ }
814
+ });
717
815
  });
718
816
  }
817
+
818
+ describe('SqliteDriver backup verification', function () {
819
+ const corruptPath = path.join(testDataPath, 'corrupt');
820
+
821
+ afterEach(function () {
822
+ if (fs.existsSync(testDataPath)) {
823
+ fs.rmSync(testDataPath, { recursive: true, force: true });
824
+ }
825
+ });
826
+
827
+ it('rejects when the written copy does not verify', async function () {
828
+ const driver = new SqliteDriver({ name: 'source', path: corruptPath });
829
+ for (let i = 0; i < 200; i++) {
830
+ await driver.set('users', `user${i}`, { name: `User ${i}` });
831
+ }
832
+
833
+ const destination = path.join(corruptPath, 'backup.db');
834
+ const originalBackup = driver.db.backup.bind(driver.db);
835
+ driver.db.backup = async dest => {
836
+ const result = await originalBackup(dest);
837
+ // Scribble over every page but the header, so the file still opens as a
838
+ // database yet fails integrity_check.
839
+ const handle = fs.openSync(dest, 'r+');
840
+ const damaged = fs.fstatSync(handle).size - 4096;
841
+ fs.writeSync(handle, Buffer.alloc(damaged, 0xff), 0, damaged, 4096);
842
+ fs.closeSync(handle);
843
+ return result;
844
+ };
845
+
846
+ await assert.rejects(
847
+ driver.backup(destination),
848
+ error => error.code === 'DEEPBASE_SQLITE_BACKUP_CORRUPT',
849
+ );
850
+ assert.ok(fs.existsSync(destination), 'corrupt copy is left in place for inspection');
851
+
852
+ await driver.disconnect();
853
+ });
854
+ });
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?.();