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 +79 -22
- package/package.json +7 -7
- package/src/SqliteDriver.js +115 -91
- package/src/busy.js +28 -0
- package/src/config.js +77 -0
- package/src/index.d.ts +42 -0
- package/src/maintenance.js +58 -0
- package/src/schema.js +20 -0
- package/test/fixtures.js +41 -0
- package/test/test-multiprocess.js +236 -0
- package/test/test.js +139 -3
- package/test/worker.js +61 -0
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
|
-
###
|
|
73
|
+
### Multi-process concurrency
|
|
68
74
|
|
|
69
|
-
Multiple instances
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
158
|
-
config.lang
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/SqliteDriver.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
53
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
return
|
|
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
|
|
255
|
+
this.setStmt.run(key, jsonValue);
|
|
226
256
|
}
|
|
227
257
|
|
|
228
258
|
async del(...keys) {
|
|
229
259
|
if (keys.length === 0) {
|
|
230
|
-
this.
|
|
231
|
-
|
|
232
|
-
|
|
260
|
+
return this._runWrite(() => {
|
|
261
|
+
this._clearTxn();
|
|
262
|
+
});
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
const key = this._pathToKey(keys);
|
|
236
|
-
|
|
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)
|
|
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
|
+
}
|
package/test/fixtures.js
ADDED
|
@@ -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('
|
|
357
|
-
it('should
|
|
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.
|
|
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?.();
|