deepbase-sqlite 3.7.0 → 3.8.1
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 +46 -9
- package/package.json +2 -2
- package/src/SqliteDriver.js +19 -2
- package/src/config.js +0 -1
- package/src/index.d.ts +32 -0
- package/src/maintenance.js +72 -0
- package/src/schema.js +15 -74
- package/test/fixtures.js +2 -18
- package/test/test-multiprocess.js +10 -39
- package/test/test.js +146 -0
package/README.md
CHANGED
|
@@ -82,7 +82,49 @@ const db2 = new DeepBase(new SqliteDriver({ name: 'mydb' }));
|
|
|
82
82
|
|
|
83
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
84
|
|
|
85
|
-
When upgrading from a version that used the in-memory sequence counter, stop all old writer processes before starting the new version.
|
|
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()` | Read-only `PRAGMA integrity_check`; returns its status verbatim. |
|
|
106
|
+
| `backup(destination)` | Read-only source, 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
|
+
`checkIntegrity()` and `backup()` open a temporary read-only connection with
|
|
111
|
+
`fileMustExist: true`, then close it. They never create, migrate, or change
|
|
112
|
+
PRAGMAs on the source database; a missing or mistyped source path fails instead
|
|
113
|
+
of producing an empty database. `backup()` creates destination parent
|
|
114
|
+
directories only after opening the source, then reopens the result read-only and
|
|
115
|
+
verifies it with `integrity_check` — a backup nobody validated is not a backup.
|
|
116
|
+
It rejects with code `DEEPBASE_SQLITE_BACKUP_CORRUPT` when the copy fails to
|
|
117
|
+
verify, leaving the bad file on disk for inspection, so a restore routine must
|
|
118
|
+
never pick a backup by timestamp alone. The copy is consistent with writes that
|
|
119
|
+
land while it runs, and it does not block the driver's write queue.
|
|
120
|
+
|
|
121
|
+
`vacuum()` and `checkpoint()` do take the write lock, so they queue behind the
|
|
122
|
+
driver's own writes and honour `busyRetry`. Run them when the application is
|
|
123
|
+
idle. `checkpoint()` rejects with code `DEEPBASE_SQLITE_NOT_WAL` on databases
|
|
124
|
+
opened with `pragma: 'none'`, which use a rollback journal and have no WAL.
|
|
125
|
+
|
|
126
|
+
Retention, rotation and scheduling stay in your application — the driver takes a
|
|
127
|
+
verified snapshot, nothing more.
|
|
86
128
|
|
|
87
129
|
### Nested Data Structure
|
|
88
130
|
|
|
@@ -92,7 +134,7 @@ Efficiently stores nested objects using a key-value schema:
|
|
|
92
134
|
- Values are stored as JSON
|
|
93
135
|
- Fast lookups for both exact keys and partial paths
|
|
94
136
|
|
|
95
|
-
Each row also stores a
|
|
137
|
+
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.
|
|
96
138
|
|
|
97
139
|
### ACID Compliance
|
|
98
140
|
|
|
@@ -148,15 +190,10 @@ CREATE TABLE deepbase (
|
|
|
148
190
|
seq INTEGER NOT NULL
|
|
149
191
|
);
|
|
150
192
|
|
|
151
|
-
CREATE
|
|
152
|
-
|
|
153
|
-
CREATE TABLE deepbase_meta (
|
|
154
|
-
key TEXT PRIMARY KEY,
|
|
155
|
-
value INTEGER NOT NULL
|
|
156
|
-
);
|
|
193
|
+
CREATE INDEX deepbase_seq_idx ON deepbase(seq);
|
|
157
194
|
```
|
|
158
195
|
|
|
159
|
-
|
|
196
|
+
Optimized PRAGMA profiles create `deepbase` `WITHOUT ROWID`.
|
|
160
197
|
|
|
161
198
|
Example data:
|
|
162
199
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepbase-sqlite",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.1",
|
|
4
4
|
"description": "⚡ DeepBase SQLite - SQLite database driver",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.cjs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"better-sqlite3": "^11.8.1"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"deepbase": "^3.
|
|
20
|
+
"deepbase": "^3.8.1"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"mocha": "^10.8.2"
|
package/src/SqliteDriver.js
CHANGED
|
@@ -4,7 +4,8 @@ import fs from 'fs';
|
|
|
4
4
|
import * as pathModule from 'path';
|
|
5
5
|
import { withBusyRetry } from './busy.js';
|
|
6
6
|
import { resolveSqliteConfig } from './config.js';
|
|
7
|
-
import {
|
|
7
|
+
import { backupFrom, checkIntegrityAt, checkpoint, vacuum } from './maintenance.js';
|
|
8
|
+
import { ensureSchema } from './schema.js';
|
|
8
9
|
|
|
9
10
|
export class SqliteDriver extends DeepBaseDriver {
|
|
10
11
|
constructor({ name, path, pragma, busyTimeoutMs, busyRetry, ...opts } = {}) {
|
|
@@ -61,7 +62,7 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
const withoutRowid = cfg ? ' WITHOUT ROWID' : '';
|
|
64
|
-
|
|
65
|
+
ensureSchema(this.db, { withoutRowid });
|
|
65
66
|
|
|
66
67
|
this.getStmt = this.db.prepare('SELECT value FROM deepbase WHERE key = ?');
|
|
67
68
|
this.setStmt = this.db.prepare(`
|
|
@@ -122,6 +123,22 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
122
123
|
this._connected = true;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
async checkIntegrity() {
|
|
127
|
+
return checkIntegrityAt(this.fileName, this.busyTimeoutMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async backup(destination) {
|
|
131
|
+
return backupFrom(this.fileName, destination, this.busyTimeoutMs);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async vacuum() {
|
|
135
|
+
return this._runWrite(() => vacuum(this.db));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async checkpoint(mode = 'PASSIVE') {
|
|
139
|
+
return this._runWrite(() => checkpoint(this.db, mode));
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
async connect() {
|
|
126
143
|
if (this._connected) return;
|
|
127
144
|
if (!this._connectPromise) {
|
package/src/config.js
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -6,6 +6,17 @@ export interface SqliteBusyRetryOptions {
|
|
|
6
6
|
maxDelayMs?: number;
|
|
7
7
|
}
|
|
8
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
|
+
|
|
9
20
|
export interface SqliteDriverOptions extends DeepBaseDriverOptions {
|
|
10
21
|
name?: string;
|
|
11
22
|
path?: string;
|
|
@@ -23,6 +34,27 @@ export class SqliteDriver extends DeepBaseDriver {
|
|
|
23
34
|
pragma: string;
|
|
24
35
|
busyTimeoutMs: number;
|
|
25
36
|
busyRetry: Required<SqliteBusyRetryOptions>;
|
|
37
|
+
|
|
38
|
+
/** Opens the existing database read-only, runs `PRAGMA integrity_check`,
|
|
39
|
+
* then closes it. Does not connect or mutate the driver database. */
|
|
40
|
+
checkIntegrity(): Promise<string>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Opens the existing source database read-only, writes an online backup to
|
|
44
|
+
* `destination`, then verifies the copy with `integrity_check`. Resolves
|
|
45
|
+
* with `destination`, or rejects with code
|
|
46
|
+
* `DEEPBASE_SQLITE_BACKUP_CORRUPT` when 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>;
|
|
26
58
|
}
|
|
27
59
|
|
|
28
60
|
export { SqliteDriver as SqliteFastDriver };
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
function checkIntegrity(db) {
|
|
8
|
+
return db.pragma('integrity_check', { simple: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function openReadonly(fileName, timeout) {
|
|
12
|
+
return new Database(fileName, {
|
|
13
|
+
readonly: true,
|
|
14
|
+
fileMustExist: true,
|
|
15
|
+
timeout,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function checkIntegrityAt(fileName, timeout) {
|
|
20
|
+
const db = openReadonly(fileName, timeout);
|
|
21
|
+
try {
|
|
22
|
+
return checkIntegrity(db);
|
|
23
|
+
} finally {
|
|
24
|
+
db.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function backupFrom(fileName, destination, timeout) {
|
|
29
|
+
const source = openReadonly(fileName, timeout);
|
|
30
|
+
try {
|
|
31
|
+
fs.mkdirSync(pathModule.dirname(pathModule.resolve(destination)), { recursive: true });
|
|
32
|
+
await source.backup(destination);
|
|
33
|
+
} finally {
|
|
34
|
+
source.close();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const status = checkIntegrityAt(destination, timeout);
|
|
38
|
+
if (status !== 'ok') {
|
|
39
|
+
const error = new Error(
|
|
40
|
+
`deepbase-sqlite: backup verification failed for ${destination}: ${status}. ` +
|
|
41
|
+
'The corrupt file was left in place for inspection; do not restore from it.',
|
|
42
|
+
);
|
|
43
|
+
error.code = 'DEEPBASE_SQLITE_BACKUP_CORRUPT';
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return destination;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function vacuum(db) {
|
|
51
|
+
db.exec('VACUUM');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function checkpoint(db, mode) {
|
|
55
|
+
if (!CHECKPOINT_MODES.includes(mode)) {
|
|
56
|
+
throw new TypeError(
|
|
57
|
+
`deepbase-sqlite: checkpoint mode must be one of ${CHECKPOINT_MODES.join(', ')}; received ${mode}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (db.pragma('journal_mode', { simple: true }) !== 'wal') {
|
|
62
|
+
const error = new Error(
|
|
63
|
+
"deepbase-sqlite: checkpoint requires journal_mode=WAL. Databases opened with pragma: 'none' " +
|
|
64
|
+
'use a rollback journal and have no WAL to checkpoint.',
|
|
65
|
+
);
|
|
66
|
+
error.code = 'DEEPBASE_SQLITE_NOT_WAL';
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [result] = db.pragma(`wal_checkpoint(${mode})`);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
package/src/schema.js
CHANGED
|
@@ -1,79 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const columns = db.prepare('PRAGMA table_info(deepbase)').all();
|
|
15
|
-
if (!columns.some(column => column.name === 'seq')) {
|
|
16
|
-
db.exec('ALTER TABLE deepbase ADD COLUMN seq INTEGER NOT NULL DEFAULT 0');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
db.exec(`
|
|
20
|
-
CREATE TABLE IF NOT EXISTS deepbase_meta (
|
|
21
|
-
key TEXT PRIMARY KEY,
|
|
22
|
-
value INTEGER NOT NULL
|
|
23
|
-
) WITHOUT ROWID
|
|
24
|
-
`);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function readSchemaVersion(db) {
|
|
28
|
-
const row = db.prepare('SELECT value FROM deepbase_meta WHERE key = ?').get(SCHEMA_VERSION_KEY);
|
|
29
|
-
return Number(row?.value ?? 0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function normalizeSequence(db) {
|
|
33
|
-
const nonPositive = db.prepare('SELECT 1 FROM deepbase WHERE seq < 1 LIMIT 1').get();
|
|
34
|
-
const needsNormalization = nonPositive || db.prepare(`
|
|
35
|
-
SELECT 1
|
|
36
|
-
FROM deepbase
|
|
37
|
-
GROUP BY seq
|
|
38
|
-
HAVING COUNT(*) > 1
|
|
39
|
-
LIMIT 1
|
|
40
|
-
`).get();
|
|
41
|
-
if (!needsNormalization) return;
|
|
42
|
-
|
|
43
|
-
const rows = db.prepare('SELECT key FROM deepbase ORDER BY seq, key').all();
|
|
44
|
-
const update = db.prepare('UPDATE deepbase SET seq = ? WHERE key = ?');
|
|
45
|
-
|
|
46
|
-
rows.forEach((row, index) => {
|
|
47
|
-
update.run(index + 1, row.key);
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function writeSchemaVersion(db) {
|
|
52
|
-
db.prepare(`
|
|
53
|
-
INSERT INTO deepbase_meta (key, value)
|
|
54
|
-
VALUES (?, ?)
|
|
55
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
56
|
-
`).run(SCHEMA_VERSION_KEY, SQLITE_CONFIG.schemaVersion);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function migrateSchema(db, { withoutRowid = '' } = {}) {
|
|
60
|
-
const migration = db.transaction(() => {
|
|
61
|
-
createBaseSchema(db, withoutRowid);
|
|
62
|
-
|
|
63
|
-
const version = readSchemaVersion(db);
|
|
64
|
-
if (version > SQLITE_CONFIG.schemaVersion) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
`deepbase-sqlite schema version ${version} is newer than supported version ${SQLITE_CONFIG.schemaVersion}`,
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (version < SQLITE_CONFIG.schemaVersion) {
|
|
71
|
-
normalizeSequence(db);
|
|
72
|
-
writeSchemaVersion(db);
|
|
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');
|
|
73
14
|
}
|
|
74
15
|
|
|
75
|
-
db.exec('CREATE
|
|
16
|
+
db.exec('CREATE INDEX IF NOT EXISTS deepbase_seq_idx ON deepbase(seq)');
|
|
76
17
|
});
|
|
77
18
|
|
|
78
|
-
|
|
19
|
+
setup.immediate();
|
|
79
20
|
}
|
package/test/fixtures.js
CHANGED
|
@@ -13,7 +13,7 @@ export function createLegacyDatabase(fileName, rows) {
|
|
|
13
13
|
db.close();
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export function createSequencedDatabase(fileName, rows
|
|
16
|
+
export function createSequencedDatabase(fileName, rows) {
|
|
17
17
|
const db = new Database(fileName);
|
|
18
18
|
db.exec(`
|
|
19
19
|
CREATE TABLE deepbase (
|
|
@@ -29,16 +29,6 @@ export function createSequencedDatabase(fileName, rows, { failMigration = false
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
insertRows(rows);
|
|
32
|
-
|
|
33
|
-
if (failMigration) {
|
|
34
|
-
db.exec(`
|
|
35
|
-
CREATE TRIGGER fail_seq_migration
|
|
36
|
-
BEFORE UPDATE OF seq ON deepbase
|
|
37
|
-
BEGIN
|
|
38
|
-
SELECT RAISE(ABORT, 'forced migration failure');
|
|
39
|
-
END
|
|
40
|
-
`);
|
|
41
|
-
}
|
|
42
32
|
db.close();
|
|
43
33
|
}
|
|
44
34
|
|
|
@@ -46,12 +36,6 @@ export function inspectDatabase(fileName) {
|
|
|
46
36
|
const db = new Database(fileName);
|
|
47
37
|
const rows = db.prepare('SELECT key, seq FROM deepbase ORDER BY seq, key').all();
|
|
48
38
|
const indexes = db.prepare("PRAGMA index_list('deepbase')").all();
|
|
49
|
-
const metaTable = db.prepare(
|
|
50
|
-
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'deepbase_meta'",
|
|
51
|
-
).get();
|
|
52
|
-
const schemaVersion = metaTable
|
|
53
|
-
? db.prepare("SELECT value FROM deepbase_meta WHERE key = 'schema_version'").get()?.value
|
|
54
|
-
: undefined;
|
|
55
39
|
db.close();
|
|
56
|
-
return { rows, indexes
|
|
40
|
+
return { rows, indexes };
|
|
57
41
|
}
|
|
@@ -3,7 +3,6 @@ import { fork } from 'child_process';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { fileURLToPath } from 'url';
|
|
6
|
-
import Database from 'better-sqlite3';
|
|
7
6
|
import { SqliteDriver } from '../src/SqliteDriver.js';
|
|
8
7
|
import { createLegacyDatabase, createSequencedDatabase, inspectDatabase } from './fixtures.js';
|
|
9
8
|
|
|
@@ -93,7 +92,7 @@ describe('SqliteDriver multi-process safety', function () {
|
|
|
93
92
|
await driver.disconnect();
|
|
94
93
|
});
|
|
95
94
|
|
|
96
|
-
it('
|
|
95
|
+
it('adds a missing seq column without rewriting legacy rows', async function () {
|
|
97
96
|
const name = 'legacy-no-seq';
|
|
98
97
|
const fileName = path.join(testDataPath, `${name}.db`);
|
|
99
98
|
createLegacyDatabase(fileName, [['b', 2], ['a', 1]]);
|
|
@@ -104,14 +103,13 @@ describe('SqliteDriver multi-process safety', function () {
|
|
|
104
103
|
|
|
105
104
|
const state = inspectDatabase(fileName);
|
|
106
105
|
assert.deepStrictEqual(state.rows, [
|
|
107
|
-
{ key: 'a', seq:
|
|
108
|
-
{ key: 'b', seq:
|
|
106
|
+
{ key: 'a', seq: 0 },
|
|
107
|
+
{ key: 'b', seq: 0 },
|
|
109
108
|
]);
|
|
110
|
-
assert.
|
|
111
|
-
assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_unique' && index.unique === 1));
|
|
109
|
+
assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_idx' && index.unique === 0));
|
|
112
110
|
});
|
|
113
111
|
|
|
114
|
-
it('
|
|
112
|
+
it('preserves duplicate historical seq values and their stable key order', async function () {
|
|
115
113
|
const name = 'duplicate-seq';
|
|
116
114
|
const fileName = path.join(testDataPath, `${name}.db`);
|
|
117
115
|
createSequencedDatabase(fileName, [
|
|
@@ -126,41 +124,14 @@ describe('SqliteDriver multi-process safety', function () {
|
|
|
126
124
|
await driver.disconnect();
|
|
127
125
|
|
|
128
126
|
assert.deepStrictEqual(inspectDatabase(fileName).rows, [
|
|
129
|
-
{ key: 'a', seq: 1 },
|
|
130
|
-
{ key: 'b', seq: 2 },
|
|
131
|
-
{ key: 'c', seq: 3 },
|
|
132
|
-
{ key: 'd', seq: 4 },
|
|
133
|
-
]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('rolls back the complete migration when normalization fails', async function () {
|
|
137
|
-
const name = 'migration-rollback';
|
|
138
|
-
const fileName = path.join(testDataPath, `${name}.db`);
|
|
139
|
-
createSequencedDatabase(fileName, [['a', 1, 0], ['b', 2, 0]], { failMigration: true });
|
|
140
|
-
|
|
141
|
-
const driver = new SqliteDriver({ name, path: testDataPath, ...retryOptions });
|
|
142
|
-
await assert.rejects(driver.connect(), /forced migration failure/);
|
|
143
|
-
|
|
144
|
-
const failedState = inspectDatabase(fileName);
|
|
145
|
-
assert.deepStrictEqual(failedState.rows, [
|
|
146
127
|
{ key: 'a', seq: 0 },
|
|
147
128
|
{ key: 'b', seq: 0 },
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const raw = new Database(fileName);
|
|
152
|
-
raw.exec('DROP TRIGGER fail_seq_migration');
|
|
153
|
-
raw.close();
|
|
154
|
-
|
|
155
|
-
await driver.connect();
|
|
156
|
-
await driver.disconnect();
|
|
157
|
-
assert.deepStrictEqual(inspectDatabase(fileName).rows, [
|
|
158
|
-
{ key: 'a', seq: 1 },
|
|
159
|
-
{ key: 'b', seq: 2 },
|
|
129
|
+
{ key: 'c', seq: 2 },
|
|
130
|
+
{ key: 'd', seq: 2 },
|
|
160
131
|
]);
|
|
161
132
|
});
|
|
162
133
|
|
|
163
|
-
it('serializes concurrent schema
|
|
134
|
+
it('serializes concurrent schema setup across processes', async function () {
|
|
164
135
|
const name = 'concurrent-migration';
|
|
165
136
|
const fileName = path.join(testDataPath, `${name}.db`);
|
|
166
137
|
createLegacyDatabase(fileName, [['value', 1]]);
|
|
@@ -176,8 +147,8 @@ describe('SqliteDriver multi-process safety', function () {
|
|
|
176
147
|
));
|
|
177
148
|
|
|
178
149
|
const state = inspectDatabase(fileName);
|
|
179
|
-
assert.
|
|
180
|
-
assert.
|
|
150
|
+
assert.deepStrictEqual(state.rows, [{ key: 'value', seq: 0 }]);
|
|
151
|
+
assert.ok(state.indexes.some(index => index.name === 'deepbase_seq_idx'));
|
|
181
152
|
});
|
|
182
153
|
|
|
183
154
|
it('keeps increments atomic across processes', async function () {
|
package/test/test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'assert';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
2
3
|
import fs from 'fs';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
@@ -728,5 +729,150 @@ for (const pragma of PRAGMA_MODES) {
|
|
|
728
729
|
assert.strictEqual(await db.get('inventory'), 500);
|
|
729
730
|
});
|
|
730
731
|
});
|
|
732
|
+
|
|
733
|
+
describe('Maintenance', function () {
|
|
734
|
+
it('reports a healthy database as ok', async function () {
|
|
735
|
+
await db.set('key', 'value');
|
|
736
|
+
assert.strictEqual(await db.getDriver(0).checkIntegrity(), 'ok');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('backs up to a verified copy containing the data', async function () {
|
|
740
|
+
await db.set('users', 'alice', { name: 'Alice' });
|
|
741
|
+
const destination = path.join(testDataPath, 'backups', `backup-${pragma}.db`);
|
|
742
|
+
|
|
743
|
+
assert.strictEqual(await db.getDriver(0).backup(destination), destination);
|
|
744
|
+
|
|
745
|
+
const copy = new DeepBase(new SqliteDriver({
|
|
746
|
+
name: `backup-${pragma}`,
|
|
747
|
+
path: path.join(testDataPath, 'backups'),
|
|
748
|
+
pragma,
|
|
749
|
+
}));
|
|
750
|
+
assert.deepStrictEqual(await copy.get('users', 'alice'), { name: 'Alice' });
|
|
751
|
+
await copy.disconnect();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('creates missing parent directories for the destination', async function () {
|
|
755
|
+
const destination = path.join(testDataPath, 'deep', 'nested', 'backup.db');
|
|
756
|
+
await db.getDriver(0).backup(destination);
|
|
757
|
+
assert.ok(fs.existsSync(destination));
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('includes writes made after the driver opened the connection', async function () {
|
|
761
|
+
await db.set('counter', 41);
|
|
762
|
+
await db.inc('counter', 1);
|
|
763
|
+
const destination = path.join(testDataPath, 'backups', `late-${pragma}.db`);
|
|
764
|
+
await db.getDriver(0).backup(destination);
|
|
765
|
+
|
|
766
|
+
const copy = new DeepBase(new SqliteDriver({
|
|
767
|
+
name: `late-${pragma}`,
|
|
768
|
+
path: path.join(testDataPath, 'backups'),
|
|
769
|
+
pragma,
|
|
770
|
+
}));
|
|
771
|
+
assert.strictEqual(await copy.get('counter'), 42);
|
|
772
|
+
await copy.disconnect();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('uses a temporary readonly connection', async function () {
|
|
776
|
+
await db.set('key', 'value');
|
|
777
|
+
const driver = db.getDriver(0);
|
|
778
|
+
await driver.disconnect();
|
|
779
|
+
|
|
780
|
+
assert.strictEqual(await driver.checkIntegrity(), 'ok');
|
|
781
|
+
assert.strictEqual(driver._connected, false);
|
|
782
|
+
assert.strictEqual(driver.db, null);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('does not create a missing source database or backup directory', async function () {
|
|
786
|
+
const driver = new SqliteDriver({
|
|
787
|
+
name: `missing-${pragma}`,
|
|
788
|
+
path: path.join(testDataPath, 'missing'),
|
|
789
|
+
pragma,
|
|
790
|
+
});
|
|
791
|
+
const destination = path.join(testDataPath, 'should-not-exist', 'backup.db');
|
|
792
|
+
|
|
793
|
+
await assert.rejects(driver.checkIntegrity());
|
|
794
|
+
await assert.rejects(driver.backup(destination));
|
|
795
|
+
|
|
796
|
+
assert.strictEqual(fs.existsSync(driver.fileName), false);
|
|
797
|
+
assert.strictEqual(fs.existsSync(path.dirname(destination)), false);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('vacuums without losing data', async function () {
|
|
801
|
+
for (let i = 0; i < 50; i++) {
|
|
802
|
+
await db.set('users', `user${i}`, { name: `User ${i}` });
|
|
803
|
+
}
|
|
804
|
+
await db.del('users', 'user0');
|
|
805
|
+
|
|
806
|
+
await db.getDriver(0).vacuum();
|
|
807
|
+
|
|
808
|
+
assert.strictEqual(await db.get('users', 'user0'), null);
|
|
809
|
+
assert.deepStrictEqual(await db.get('users', 'user49'), { name: 'User 49' });
|
|
810
|
+
assert.strictEqual(await db.getDriver(0).checkIntegrity(), 'ok');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('rejects an unknown checkpoint mode', async function () {
|
|
814
|
+
await assert.rejects(db.getDriver(0).checkpoint('SOMETIMES'), TypeError);
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
if (pragma === 'none') {
|
|
818
|
+
it('refuses to checkpoint a rollback-journal database', async function () {
|
|
819
|
+
await db.set('key', 'value');
|
|
820
|
+
await assert.rejects(
|
|
821
|
+
db.getDriver(0).checkpoint(),
|
|
822
|
+
error => error.code === 'DEEPBASE_SQLITE_NOT_WAL',
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
} else {
|
|
826
|
+
it('checkpoints the WAL back into the database file', async function () {
|
|
827
|
+
await db.set('key', 'value');
|
|
828
|
+
const result = await db.getDriver(0).checkpoint('TRUNCATE');
|
|
829
|
+
assert.strictEqual(result.busy, 0);
|
|
830
|
+
assert.strictEqual(result.log, 0, 'TRUNCATE leaves an empty WAL');
|
|
831
|
+
assert.strictEqual(await db.get('key'), 'value');
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
});
|
|
731
835
|
});
|
|
732
836
|
}
|
|
837
|
+
|
|
838
|
+
describe('SqliteDriver backup verification', function () {
|
|
839
|
+
const corruptPath = path.join(testDataPath, 'corrupt');
|
|
840
|
+
|
|
841
|
+
afterEach(function () {
|
|
842
|
+
if (fs.existsSync(testDataPath)) {
|
|
843
|
+
fs.rmSync(testDataPath, { recursive: true, force: true });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('rejects when the written copy does not verify', async function () {
|
|
848
|
+
const driver = new SqliteDriver({ name: 'source', path: corruptPath });
|
|
849
|
+
for (let i = 0; i < 200; i++) {
|
|
850
|
+
await driver.set('users', `user${i}`, { name: `User ${i}` });
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const destination = path.join(corruptPath, 'backup.db');
|
|
854
|
+
const originalBackup = Database.prototype.backup;
|
|
855
|
+
Database.prototype.backup = async function (dest, ...args) {
|
|
856
|
+
const result = await originalBackup.call(this, dest, ...args);
|
|
857
|
+
// Scribble over every page but the header, so the file still opens as a
|
|
858
|
+
// database yet fails integrity_check.
|
|
859
|
+
const handle = fs.openSync(dest, 'r+');
|
|
860
|
+
const damaged = fs.fstatSync(handle).size - 4096;
|
|
861
|
+
fs.writeSync(handle, Buffer.alloc(damaged, 0xff), 0, damaged, 4096);
|
|
862
|
+
fs.closeSync(handle);
|
|
863
|
+
return result;
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
try {
|
|
867
|
+
await assert.rejects(
|
|
868
|
+
driver.backup(destination),
|
|
869
|
+
error => error.code === 'DEEPBASE_SQLITE_BACKUP_CORRUPT',
|
|
870
|
+
);
|
|
871
|
+
} finally {
|
|
872
|
+
Database.prototype.backup = originalBackup;
|
|
873
|
+
}
|
|
874
|
+
assert.ok(fs.existsSync(destination), 'corrupt copy is left in place for inspection');
|
|
875
|
+
|
|
876
|
+
await driver.disconnect();
|
|
877
|
+
});
|
|
878
|
+
});
|