resplite 1.4.2 → 1.4.6
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 +13 -2
- package/package.json +1 -1
- package/scripts/benchmark-redis-vs-resplite.js +47 -4
- package/spec/08-type-sorted-sets.md +2 -2
- package/src/embed.js +4 -2
- package/src/engine/engine.js +5 -1
- package/src/index.js +2 -1
- package/src/storage/sqlite/db.js +3 -3
- package/src/storage/sqlite/hashes.js +66 -7
- package/src/storage/sqlite/keys.js +59 -5
- package/src/storage/sqlite/pragmas.js +18 -2
- package/src/storage/sqlite/schema.js +12 -0
- package/src/storage/sqlite/zsets.js +62 -8
- package/test/integration/embed.test.js +11 -0
- package/test/integration/hashes.test.js +30 -0
- package/test/integration/zsets.test.js +39 -0
- package/test/unit/pragmas.test.js +24 -0
- package/docs/generated/migration-apply-dirty-concurrency-progress.md +0 -7
- package/docs/generated/migration-bulk-concurrency.md +0 -7
- package/docs/generated/migration-bulk-eta-onprogress.md +0 -7
package/README.md
CHANGED
|
@@ -517,16 +517,27 @@ redis-cli -p 6380 PING
|
|
|
517
517
|
| `RESPLITE_DB` | `./data.db` | SQLite database file |
|
|
518
518
|
| `RESPLITE_PRAGMA_TEMPLATE` | `default` | SQLite PRAGMA preset (see below) |
|
|
519
519
|
|
|
520
|
-
### PRAGMA
|
|
520
|
+
### PRAGMA (convention over configuration)
|
|
521
|
+
|
|
522
|
+
A **template** is applied by default (`default`); you usually don't pass anything. Only pass **overrides** when you need to change specific pragmas.
|
|
521
523
|
|
|
522
524
|
| Template | Description | Key settings |
|
|
523
|
-
|
|
525
|
+
|----------|-------------|--------------|
|
|
524
526
|
| `default` | Balanced durability and speed (recommended) | WAL, synchronous=NORMAL, 20 MB cache |
|
|
525
527
|
| `performance` | Maximum throughput, reduced crash safety | WAL, synchronous=OFF, 64 MB cache, 512 MB mmap, exclusive locking |
|
|
526
528
|
| `safety` | Crash-safe writes at the cost of speed | WAL, synchronous=FULL, 20 MB cache |
|
|
527
529
|
| `minimal` | Only WAL + foreign keys | WAL, foreign_keys=ON |
|
|
528
530
|
| `none` | No pragmas applied, pure SQLite defaults | - |
|
|
529
531
|
|
|
532
|
+
Override specific pragmas only when needed. Overrides are applied after the template. Example — 1 GB cache:
|
|
533
|
+
|
|
534
|
+
```javascript
|
|
535
|
+
const srv = await createRESPlite({
|
|
536
|
+
db: './data.db',
|
|
537
|
+
pragma: { cache_size: -1024 * 1024 }, // negative = KiB, so 1 GiB
|
|
538
|
+
});
|
|
539
|
+
```
|
|
540
|
+
|
|
530
541
|
## Benchmark (Redis vs RESPLite)
|
|
531
542
|
|
|
532
543
|
A typical comparison is **Redis (for example, in Docker)** on one side and **RESPLite locally** on the other. In that setup, RESPLite often shows **better latency** because it avoids Docker networking and runs in the same process or host. The benchmark below uses RESPLite with the **default** PRAGMA template only.
|
package/package.json
CHANGED
|
@@ -346,6 +346,13 @@ async function benchZcount(client, n) {
|
|
|
346
346
|
for (let i = 0; i < n; i++) await client.sendCommand(['ZCOUNT', key, '0', '99']);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
async function benchZcard(client, n) {
|
|
350
|
+
const key = 'bm:zset:card';
|
|
351
|
+
await client.del(key);
|
|
352
|
+
await client.zAdd(key, Array.from({ length: 100 }, (_, i) => ({ score: i, value: `m${i}` })));
|
|
353
|
+
for (let i = 0; i < n; i++) await client.sendCommand(['ZCARD', key]);
|
|
354
|
+
}
|
|
355
|
+
|
|
349
356
|
async function benchZincrby(client, n) {
|
|
350
357
|
const key = 'bm:zset:incr';
|
|
351
358
|
await client.del(key);
|
|
@@ -353,7 +360,31 @@ async function benchZincrby(client, n) {
|
|
|
353
360
|
for (let i = 0; i < n; i++) await client.sendCommand(['ZINCRBY', key, '1', 'member']);
|
|
354
361
|
}
|
|
355
362
|
|
|
356
|
-
async function
|
|
363
|
+
async function benchZremrangebyrankPure(client, n) {
|
|
364
|
+
const key = 'bm:zset:remrank:pure';
|
|
365
|
+
const seed = Array.from({ length: 100 }, (_, j) => ({ score: j, value: `m${j}` }));
|
|
366
|
+
const refill = Array.from({ length: 10 }, (_, j) => ({ score: j, value: `m${j}` }));
|
|
367
|
+
await client.del(key);
|
|
368
|
+
await client.zAdd(key, seed);
|
|
369
|
+
for (let i = 0; i < n; i++) {
|
|
370
|
+
await client.sendCommand(['ZREMRANGEBYRANK', key, '0', '9']);
|
|
371
|
+
await client.zAdd(key, refill);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function benchZremrangebyscorePure(client, n) {
|
|
376
|
+
const key = 'bm:zset:remscore:pure';
|
|
377
|
+
const seed = Array.from({ length: 100 }, (_, j) => ({ score: j, value: `m${j}` }));
|
|
378
|
+
const refill = Array.from({ length: 10 }, (_, j) => ({ score: j, value: `m${j}` }));
|
|
379
|
+
await client.del(key);
|
|
380
|
+
await client.zAdd(key, seed);
|
|
381
|
+
for (let i = 0; i < n; i++) {
|
|
382
|
+
await client.sendCommand(['ZREMRANGEBYSCORE', key, '0', '9']);
|
|
383
|
+
await client.zAdd(key, refill);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function benchZremrangebyrankChurn(client, n) {
|
|
357
388
|
const key = 'bm:zset:remrank';
|
|
358
389
|
for (let i = 0; i < n; i++) {
|
|
359
390
|
await client.del(key);
|
|
@@ -362,7 +393,7 @@ async function benchZremrangebyrank(client, n) {
|
|
|
362
393
|
}
|
|
363
394
|
}
|
|
364
395
|
|
|
365
|
-
async function
|
|
396
|
+
async function benchZremrangebyscoreChurn(client, n) {
|
|
366
397
|
const key = 'bm:zset:remscore';
|
|
367
398
|
for (let i = 0; i < n; i++) {
|
|
368
399
|
await client.del(key);
|
|
@@ -390,6 +421,14 @@ async function benchSrandmember(client, n) {
|
|
|
390
421
|
for (let i = 0; i < n; i++) await client.sendCommand(['SRANDMEMBER', key]);
|
|
391
422
|
}
|
|
392
423
|
|
|
424
|
+
async function benchHincrby(client, n) {
|
|
425
|
+
const key = 'bm:hash:incr';
|
|
426
|
+
const field = 'counter';
|
|
427
|
+
await client.del(key);
|
|
428
|
+
await client.hSet(key, field, '0');
|
|
429
|
+
for (let i = 0; i < n; i++) await client.sendCommand(['HINCRBY', key, field, '1']);
|
|
430
|
+
}
|
|
431
|
+
|
|
393
432
|
const FT_INDEX = 'bm_ft_idx';
|
|
394
433
|
const FT_DOCS = 50;
|
|
395
434
|
|
|
@@ -447,11 +486,15 @@ const SUITES = [
|
|
|
447
486
|
{ name: 'LTRIM', fn: benchLtrim, iterScale: 1 },
|
|
448
487
|
{ name: 'RENAME', fn: benchRename, iterScale: 1 },
|
|
449
488
|
{ name: 'ZCOUNT', fn: benchZcount, iterScale: 1 },
|
|
489
|
+
{ name: 'ZCARD(100)', fn: benchZcard, iterScale: 1 },
|
|
450
490
|
{ name: 'ZINCRBY', fn: benchZincrby, iterScale: 1 },
|
|
451
|
-
{ name: 'ZREMRANGEBYRANK', fn:
|
|
452
|
-
{ name: 'ZREMRANGEBYSCORE', fn:
|
|
491
|
+
{ name: 'ZREMRANGEBYRANK (pure)', fn: benchZremrangebyrankPure, iterScale: 1 },
|
|
492
|
+
{ name: 'ZREMRANGEBYSCORE (pure)', fn: benchZremrangebyscorePure, iterScale: 1 },
|
|
493
|
+
{ name: 'ZREMRANGEBYRANK (churn)', fn: benchZremrangebyrankChurn, iterScale: 1 },
|
|
494
|
+
{ name: 'ZREMRANGEBYSCORE (churn)', fn: benchZremrangebyscoreChurn, iterScale: 1 },
|
|
453
495
|
{ name: 'SPOP', fn: benchSpop, iterScale: 1 },
|
|
454
496
|
{ name: 'SRANDMEMBER', fn: benchSrandmember, iterScale: 1 },
|
|
497
|
+
{ name: 'HINCRBY', fn: benchHincrby, iterScale: 1 },
|
|
455
498
|
{ name: 'FT.SEARCH', fn: benchFtSearch, iterScale: 1 },
|
|
456
499
|
];
|
|
457
500
|
|
|
@@ -91,8 +91,8 @@ Notes:
|
|
|
91
91
|
|
|
92
92
|
* Return cardinality:
|
|
93
93
|
|
|
94
|
-
* Prefer
|
|
95
|
-
*
|
|
94
|
+
* Prefer metadata counter (`redis_keys.zset_count`) for O(1) reads.
|
|
95
|
+
* Backward compatibility: if counter is missing/null, hydrate once from `SELECT COUNT(*)`.
|
|
96
96
|
|
|
97
97
|
### C.5.4 ZSCORE
|
|
98
98
|
|
package/src/embed.js
CHANGED
|
@@ -33,7 +33,8 @@ export { handleConnection, createEngine, openDb };
|
|
|
33
33
|
* @param {string} [options.db=':memory:'] SQLite file path, or ':memory:' for in-memory.
|
|
34
34
|
* @param {string} [options.host='127.0.0.1'] Host to listen on.
|
|
35
35
|
* @param {number} [options.port=0] Port to listen on (0 = OS-assigned).
|
|
36
|
-
* @param {string} [options.pragmaTemplate='default'] PRAGMA preset (default|performance|safety|minimal|none).
|
|
36
|
+
* @param {string} [options.pragmaTemplate='default'] PRAGMA preset (default|performance|safety|minimal|none). Convention: this template is applied by default; no config needed.
|
|
37
|
+
* @param {Record<string, string|number>} [options.pragma] Override specific pragmas only when needed (e.g. { synchronous: 'FULL' }). Applied after the template.
|
|
37
38
|
* @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
|
|
38
39
|
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to call close(). Set false if you handle shutdown yourself to avoid double handlers.
|
|
39
40
|
* @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
|
|
@@ -43,10 +44,11 @@ export async function createRESPlite({
|
|
|
43
44
|
host = '127.0.0.1',
|
|
44
45
|
port = 0,
|
|
45
46
|
pragmaTemplate = 'default',
|
|
47
|
+
pragma,
|
|
46
48
|
hooks = {},
|
|
47
49
|
gracefulShutdown = true,
|
|
48
50
|
} = {}) {
|
|
49
|
-
const db = openDb(dbPath, { pragmaTemplate });
|
|
51
|
+
const db = openDb(dbPath, { pragmaTemplate, pragma });
|
|
50
52
|
const engine = createEngine({ db });
|
|
51
53
|
const connections = new Set();
|
|
52
54
|
|
package/src/engine/engine.js
CHANGED
|
@@ -567,7 +567,11 @@ export function createEngine(opts = {}) {
|
|
|
567
567
|
if (k.equals(nk)) return;
|
|
568
568
|
runInTransaction(db, () => {
|
|
569
569
|
if (keys.get(nk)) keys.delete(nk);
|
|
570
|
-
keys.set(nk, meta.type, {
|
|
570
|
+
keys.set(nk, meta.type, {
|
|
571
|
+
expiresAt: meta.expiresAt,
|
|
572
|
+
hashCount: meta.type === KEY_TYPES.HASH ? meta.hashCount : undefined,
|
|
573
|
+
zsetCount: meta.type === KEY_TYPES.ZSET ? meta.zsetCount : undefined,
|
|
574
|
+
});
|
|
571
575
|
switch (meta.type) {
|
|
572
576
|
case KEY_TYPES.STRING:
|
|
573
577
|
strings.copyKey(k, nk);
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const DEFAULT_PORT = 6379;
|
|
|
24
24
|
* @param {number} [options.port]
|
|
25
25
|
* @param {string} [options.dbPath]
|
|
26
26
|
* @param {string} [options.pragmaTemplate]
|
|
27
|
+
* @param {Record<string, string|number>} [options.pragma] Override specific pragmas when needed (e.g. { synchronous: 'FULL' }). Convention: template is applied by default.
|
|
27
28
|
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to close server and DB. Set false if you handle shutdown yourself.
|
|
28
29
|
*/
|
|
29
30
|
export function startServer(options = {}) {
|
|
@@ -32,7 +33,7 @@ export function startServer(options = {}) {
|
|
|
32
33
|
const pragmaTemplate = options.pragmaTemplate ?? process.env.RESPLITE_PRAGMA_TEMPLATE ?? 'default';
|
|
33
34
|
const gracefulShutdown = options.gracefulShutdown !== false;
|
|
34
35
|
|
|
35
|
-
const db = openDb(dbPath, { pragmaTemplate });
|
|
36
|
+
const db = openDb(dbPath, { pragmaTemplate, pragma: options.pragma });
|
|
36
37
|
const cache = createCache({ enabled: true });
|
|
37
38
|
const engine = createEngine({ db, cache });
|
|
38
39
|
const sweeper = createExpirationSweeper({
|
package/src/storage/sqlite/db.js
CHANGED
|
@@ -12,7 +12,7 @@ import { applyMigrationSchema } from './migration-schema.js';
|
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {string} dbPath - Database file path (or ':memory:')
|
|
15
|
-
* @param {object} [options] - Options: pragmaTemplate (default|performance|safety|minimal), plus any better-sqlite3 options
|
|
15
|
+
* @param {object} [options] - Options: pragmaTemplate (default|performance|safety|minimal), pragma (custom key-value overrides), plus any better-sqlite3 options
|
|
16
16
|
* @returns {import('better-sqlite3').Database}
|
|
17
17
|
*/
|
|
18
18
|
export function openDb(dbPath, options = {}) {
|
|
@@ -20,9 +20,9 @@ export function openDb(dbPath, options = {}) {
|
|
|
20
20
|
const dir = path.dirname(dbPath);
|
|
21
21
|
if (dir) fs.mkdirSync(dir, { recursive: true });
|
|
22
22
|
}
|
|
23
|
-
const { pragmaTemplate = 'default', ...dbOptions } = options;
|
|
23
|
+
const { pragmaTemplate = 'default', pragma: customPragma, ...dbOptions } = options;
|
|
24
24
|
const db = new Database(dbPath, dbOptions);
|
|
25
|
-
applyPragmas(db, pragmaTemplate);
|
|
25
|
+
applyPragmas(db, pragmaTemplate, customPragma);
|
|
26
26
|
applySchema(db);
|
|
27
27
|
applyMigrationSchema(db);
|
|
28
28
|
return db;
|
|
@@ -32,15 +32,31 @@ export function createHashesStorage(db, keys) {
|
|
|
32
32
|
runInTransaction(db, () => {
|
|
33
33
|
const now = options.updatedAt ?? Date.now();
|
|
34
34
|
const meta = keys.get(key);
|
|
35
|
+
let knownCount = 0;
|
|
35
36
|
if (meta) {
|
|
36
37
|
if (meta.type !== KEY_TYPES.HASH) {
|
|
37
38
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
38
39
|
}
|
|
39
40
|
keys.bumpVersion(key);
|
|
41
|
+
if (meta.hashCount == null) {
|
|
42
|
+
const row = countStmt.get(key);
|
|
43
|
+
knownCount = (row && row.n) || 0;
|
|
44
|
+
keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
|
|
45
|
+
} else {
|
|
46
|
+
knownCount = meta.hashCount;
|
|
47
|
+
}
|
|
40
48
|
} else {
|
|
41
|
-
keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
|
|
49
|
+
keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
|
|
42
50
|
}
|
|
51
|
+
const existed = getStmt.get(key, field) != null;
|
|
43
52
|
insertStmt.run(key, field, value);
|
|
53
|
+
if (!existed) {
|
|
54
|
+
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
55
|
+
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
56
|
+
} else if (meta && meta.hashCount == null) {
|
|
57
|
+
// Legacy rows may have null counters; persist hydrated value.
|
|
58
|
+
keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
|
|
59
|
+
}
|
|
44
60
|
});
|
|
45
61
|
},
|
|
46
62
|
|
|
@@ -48,39 +64,70 @@ export function createHashesStorage(db, keys) {
|
|
|
48
64
|
runInTransaction(db, () => {
|
|
49
65
|
const now = options.updatedAt ?? Date.now();
|
|
50
66
|
const meta = keys.get(key);
|
|
67
|
+
let knownCount = 0;
|
|
51
68
|
if (meta) {
|
|
52
69
|
if (meta.type !== KEY_TYPES.HASH) {
|
|
53
70
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
54
71
|
}
|
|
55
72
|
keys.bumpVersion(key);
|
|
73
|
+
if (meta.hashCount == null) {
|
|
74
|
+
const row = countStmt.get(key);
|
|
75
|
+
knownCount = (row && row.n) || 0;
|
|
76
|
+
keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
|
|
77
|
+
} else {
|
|
78
|
+
knownCount = meta.hashCount;
|
|
79
|
+
}
|
|
56
80
|
} else {
|
|
57
|
-
keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
|
|
81
|
+
keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
|
|
58
82
|
}
|
|
83
|
+
let added = 0;
|
|
59
84
|
for (let i = 0; i < pairs.length; i += 2) {
|
|
85
|
+
const existed = getStmt.get(key, pairs[i]) != null;
|
|
60
86
|
insertStmt.run(key, pairs[i], pairs[i + 1]);
|
|
87
|
+
if (!existed) added++;
|
|
88
|
+
}
|
|
89
|
+
if (added > 0) {
|
|
90
|
+
if (meta) keys.incrHashCount(key, added, { touchUpdatedAt: false });
|
|
91
|
+
else keys.setHashCount(key, added, { touchUpdatedAt: false });
|
|
92
|
+
} else if (meta && meta.hashCount == null) {
|
|
93
|
+
// Legacy rows may have null counters; persist hydrated value.
|
|
94
|
+
keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
|
|
61
95
|
}
|
|
62
96
|
});
|
|
63
97
|
},
|
|
64
98
|
|
|
65
99
|
delete(key, fields) {
|
|
66
100
|
return runInTransaction(db, () => {
|
|
101
|
+
const meta = keys.get(key);
|
|
102
|
+
const before = meta && meta.hashCount != null ? meta.hashCount : null;
|
|
67
103
|
let n = 0;
|
|
68
104
|
for (const field of fields) {
|
|
69
105
|
const r = deleteStmt.run(key, field);
|
|
70
106
|
n += r.changes;
|
|
71
107
|
}
|
|
72
|
-
const remaining = (countStmt.get(key) || {}).n ?? 0;
|
|
108
|
+
const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n ?? 0);
|
|
73
109
|
if (remaining === 0) {
|
|
74
110
|
deleteAllStmt.run(key);
|
|
75
111
|
keys.delete(key);
|
|
112
|
+
} else if (n > 0) {
|
|
113
|
+
keys.setHashCount(key, remaining, { touchUpdatedAt: false });
|
|
76
114
|
}
|
|
77
115
|
return n;
|
|
78
116
|
});
|
|
79
117
|
},
|
|
80
118
|
|
|
81
119
|
count(key) {
|
|
120
|
+
const meta = keys.get(key);
|
|
121
|
+
if (meta && meta.type === KEY_TYPES.HASH && meta.hashCount != null) {
|
|
122
|
+
return meta.hashCount;
|
|
123
|
+
}
|
|
82
124
|
const row = countStmt.get(key);
|
|
83
|
-
|
|
125
|
+
const n = row ? row.n : 0;
|
|
126
|
+
if (meta && meta.type === KEY_TYPES.HASH && meta.hashCount == null) {
|
|
127
|
+
// One-time hydration for databases created before hash_count existed.
|
|
128
|
+
keys.setHashCount(key, n, { touchUpdatedAt: false });
|
|
129
|
+
}
|
|
130
|
+
return n;
|
|
84
131
|
},
|
|
85
132
|
|
|
86
133
|
incr(key, field, delta, options = {}) {
|
|
@@ -91,15 +138,24 @@ export function createHashesStorage(db, keys) {
|
|
|
91
138
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
92
139
|
}
|
|
93
140
|
if (!meta) {
|
|
94
|
-
keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
|
|
141
|
+
keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
|
|
95
142
|
} else {
|
|
96
143
|
keys.bumpVersion(key);
|
|
144
|
+
if (meta.hashCount == null) {
|
|
145
|
+
const row = countStmt.get(key);
|
|
146
|
+
const hydrated = (row && row.n) || 0;
|
|
147
|
+
keys.setHashCount(key, hydrated, { touchUpdatedAt: false });
|
|
148
|
+
}
|
|
97
149
|
}
|
|
98
150
|
const cur = getStmt.get(key, field);
|
|
99
151
|
const num = cur == null ? 0 : parseInt(cur.value.toString('utf8'), 10);
|
|
100
152
|
if (Number.isNaN(num)) throw new Error('ERR hash value is not an integer');
|
|
101
153
|
const next = num + delta;
|
|
102
154
|
insertStmt.run(key, field, Buffer.from(String(next), 'utf8'));
|
|
155
|
+
if (cur == null) {
|
|
156
|
+
if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
|
|
157
|
+
else keys.setHashCount(key, 1, { touchUpdatedAt: false });
|
|
158
|
+
}
|
|
103
159
|
return next;
|
|
104
160
|
});
|
|
105
161
|
},
|
|
@@ -107,9 +163,12 @@ export function createHashesStorage(db, keys) {
|
|
|
107
163
|
/** Copy all field/value rows from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
|
|
108
164
|
copyKey(oldKey, newKey) {
|
|
109
165
|
const rows = getAllStmt.all(oldKey);
|
|
110
|
-
for (
|
|
111
|
-
insertStmt.run(newKey,
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
insertStmt.run(newKey, row[0], row[1]);
|
|
112
168
|
}
|
|
169
|
+
const sourceMeta = keys.get(oldKey);
|
|
170
|
+
const nextCount = sourceMeta && sourceMeta.hashCount != null ? sourceMeta.hashCount : rows.length;
|
|
171
|
+
keys.setHashCount(newKey, nextCount, { touchUpdatedAt: false });
|
|
113
172
|
},
|
|
114
173
|
};
|
|
115
174
|
}
|
|
@@ -9,16 +9,28 @@ import { KEY_TYPES } from './schema.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export function createKeysStorage(db) {
|
|
11
11
|
const getByKey = db.prepare(
|
|
12
|
-
'SELECT key, type, expires_at AS expiresAt, version, updated_at AS updatedAt FROM redis_keys WHERE key = ?'
|
|
12
|
+
'SELECT key, type, expires_at AS expiresAt, hash_count AS hashCount, zset_count AS zsetCount, version, updated_at AS updatedAt FROM redis_keys WHERE key = ?'
|
|
13
13
|
);
|
|
14
14
|
const insert = db.prepare(
|
|
15
|
-
`INSERT INTO redis_keys (key, type, expires_at, version, updated_at) VALUES (?, ?, ?, 1, ?)`
|
|
15
|
+
`INSERT INTO redis_keys (key, type, expires_at, hash_count, zset_count, version, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?)`
|
|
16
16
|
);
|
|
17
17
|
const updateMeta = db.prepare(
|
|
18
|
-
'UPDATE redis_keys SET type = ?, expires_at = ?, version = version + 1, updated_at = ? WHERE key = ?'
|
|
18
|
+
'UPDATE redis_keys SET type = ?, expires_at = ?, hash_count = ?, zset_count = ?, version = version + 1, updated_at = ? WHERE key = ?'
|
|
19
19
|
);
|
|
20
20
|
const updateExpires = db.prepare('UPDATE redis_keys SET expires_at = ?, updated_at = ? WHERE key = ?');
|
|
21
21
|
const updateVersion = db.prepare('UPDATE redis_keys SET version = version + 1, updated_at = ? WHERE key = ?');
|
|
22
|
+
const updateHashCount = db.prepare('UPDATE redis_keys SET hash_count = ?, updated_at = ? WHERE key = ?');
|
|
23
|
+
const updateHashCountOnly = db.prepare('UPDATE redis_keys SET hash_count = ? WHERE key = ?');
|
|
24
|
+
const incrHashCount = db.prepare(
|
|
25
|
+
'UPDATE redis_keys SET hash_count = COALESCE(hash_count, 0) + ?, updated_at = ? WHERE key = ?'
|
|
26
|
+
);
|
|
27
|
+
const incrHashCountOnly = db.prepare('UPDATE redis_keys SET hash_count = COALESCE(hash_count, 0) + ? WHERE key = ?');
|
|
28
|
+
const updateZsetCount = db.prepare('UPDATE redis_keys SET zset_count = ?, updated_at = ? WHERE key = ?');
|
|
29
|
+
const updateZsetCountOnly = db.prepare('UPDATE redis_keys SET zset_count = ? WHERE key = ?');
|
|
30
|
+
const incrZsetCount = db.prepare(
|
|
31
|
+
'UPDATE redis_keys SET zset_count = COALESCE(zset_count, 0) + ?, updated_at = ? WHERE key = ?'
|
|
32
|
+
);
|
|
33
|
+
const incrZsetCountOnly = db.prepare('UPDATE redis_keys SET zset_count = COALESCE(zset_count, 0) + ? WHERE key = ?');
|
|
22
34
|
const deleteByKey = db.prepare('DELETE FROM redis_keys WHERE key = ?');
|
|
23
35
|
const deleteExpiredStmt = db.prepare('DELETE FROM redis_keys WHERE expires_at IS NOT NULL AND expires_at <= ?');
|
|
24
36
|
const countAll = db.prepare('SELECT COUNT(*) AS n FROM redis_keys').pluck();
|
|
@@ -38,10 +50,16 @@ export function createKeysStorage(db) {
|
|
|
38
50
|
const now = options.updatedAt ?? Date.now();
|
|
39
51
|
const expiresAt = options.expiresAt ?? null;
|
|
40
52
|
const existing = getByKey.get(key);
|
|
53
|
+
const hashCount = type === KEY_TYPES.HASH
|
|
54
|
+
? (options.hashCount ?? existing?.hashCount ?? 0)
|
|
55
|
+
: null;
|
|
56
|
+
const zsetCount = type === KEY_TYPES.ZSET
|
|
57
|
+
? (options.zsetCount ?? existing?.zsetCount ?? 0)
|
|
58
|
+
: null;
|
|
41
59
|
if (existing) {
|
|
42
|
-
updateMeta.run(type, expiresAt, now, key);
|
|
60
|
+
updateMeta.run(type, expiresAt, hashCount, zsetCount, now, key);
|
|
43
61
|
} else {
|
|
44
|
-
insert.run(key, type, expiresAt, now);
|
|
62
|
+
insert.run(key, type, expiresAt, hashCount, zsetCount, now);
|
|
45
63
|
}
|
|
46
64
|
},
|
|
47
65
|
|
|
@@ -53,6 +71,42 @@ export function createKeysStorage(db) {
|
|
|
53
71
|
updateVersion.run(Date.now(), key);
|
|
54
72
|
},
|
|
55
73
|
|
|
74
|
+
setHashCount(key, hashCount, options = {}) {
|
|
75
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
76
|
+
if (touchUpdatedAt) {
|
|
77
|
+
updateHashCount.run(hashCount, options.updatedAt ?? Date.now(), key);
|
|
78
|
+
} else {
|
|
79
|
+
updateHashCountOnly.run(hashCount, key);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
incrHashCount(key, delta, options = {}) {
|
|
84
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
85
|
+
if (touchUpdatedAt) {
|
|
86
|
+
incrHashCount.run(delta, options.updatedAt ?? Date.now(), key);
|
|
87
|
+
} else {
|
|
88
|
+
incrHashCountOnly.run(delta, key);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
setZsetCount(key, zsetCount, options = {}) {
|
|
93
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
94
|
+
if (touchUpdatedAt) {
|
|
95
|
+
updateZsetCount.run(zsetCount, options.updatedAt ?? Date.now(), key);
|
|
96
|
+
} else {
|
|
97
|
+
updateZsetCountOnly.run(zsetCount, key);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
incrZsetCount(key, delta, options = {}) {
|
|
102
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
103
|
+
if (touchUpdatedAt) {
|
|
104
|
+
incrZsetCount.run(delta, options.updatedAt ?? Date.now(), key);
|
|
105
|
+
} else {
|
|
106
|
+
incrZsetCountOnly.run(delta, key);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
56
110
|
delete(key) {
|
|
57
111
|
return deleteByKey.run(key);
|
|
58
112
|
},
|
|
@@ -69,13 +69,29 @@ export function getPragmasForTemplate(name) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* Apply
|
|
72
|
+
* Apply custom pragma key-value object to an open database.
|
|
73
|
+
* @param {import('better-sqlite3').Database} db
|
|
74
|
+
* @param {Record<string, string|number>} obj - e.g. { journal_mode: 'WAL', cache_size: -64000 }
|
|
75
|
+
*/
|
|
76
|
+
function applyPragmaObject(db, obj) {
|
|
77
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
78
|
+
if (val === undefined) continue;
|
|
79
|
+
db.exec(`PRAGMA ${key}=${val};`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Apply pragmas from a named template and optional overrides to an open database.
|
|
73
85
|
* @param {import('better-sqlite3').Database} db
|
|
74
86
|
* @param {string} [templateName='default'] - One of: default, performance, safety, minimal, none
|
|
87
|
+
* @param {Record<string, string|number>} [customPragma] - Optional overrides, e.g. { synchronous: 'FULL', cache_size: -10000 }
|
|
75
88
|
*/
|
|
76
|
-
export function applyPragmas(db, templateName = 'default') {
|
|
89
|
+
export function applyPragmas(db, templateName = 'default', customPragma = undefined) {
|
|
77
90
|
const pragmas = getPragmasForTemplate(templateName);
|
|
78
91
|
for (const sql of pragmas) {
|
|
79
92
|
db.exec(sql);
|
|
80
93
|
}
|
|
94
|
+
if (customPragma && typeof customPragma === 'object' && Object.keys(customPragma).length > 0) {
|
|
95
|
+
applyPragmaObject(db, customPragma);
|
|
96
|
+
}
|
|
81
97
|
}
|
|
@@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS redis_keys (
|
|
|
7
7
|
key BLOB PRIMARY KEY,
|
|
8
8
|
type INTEGER NOT NULL,
|
|
9
9
|
expires_at INTEGER,
|
|
10
|
+
hash_count INTEGER,
|
|
11
|
+
zset_count INTEGER,
|
|
10
12
|
version INTEGER NOT NULL DEFAULT 1,
|
|
11
13
|
updated_at INTEGER NOT NULL
|
|
12
14
|
);
|
|
@@ -86,4 +88,14 @@ export const KEY_TYPES = {
|
|
|
86
88
|
*/
|
|
87
89
|
export function applySchema(db) {
|
|
88
90
|
db.exec(SCHEMA);
|
|
91
|
+
// Backward-compatible migration for databases created before count columns existed.
|
|
92
|
+
const cols = db.prepare('PRAGMA table_info(redis_keys)').all();
|
|
93
|
+
const hasHashCount = cols.some((c) => c.name === 'hash_count');
|
|
94
|
+
const hasZsetCount = cols.some((c) => c.name === 'zset_count');
|
|
95
|
+
if (!hasHashCount) {
|
|
96
|
+
db.exec('ALTER TABLE redis_keys ADD COLUMN hash_count INTEGER;');
|
|
97
|
+
}
|
|
98
|
+
if (!hasZsetCount) {
|
|
99
|
+
db.exec('ALTER TABLE redis_keys ADD COLUMN zset_count INTEGER;');
|
|
100
|
+
}
|
|
89
101
|
}
|
|
@@ -26,6 +26,12 @@ export function createZsetsStorage(db, keys) {
|
|
|
26
26
|
`INSERT INTO redis_zsets (key, member, score) VALUES (?, ?, ?)
|
|
27
27
|
ON CONFLICT(key, member) DO UPDATE SET score = excluded.score`
|
|
28
28
|
);
|
|
29
|
+
const insertIgnoreStmt = db.prepare(
|
|
30
|
+
'INSERT OR IGNORE INTO redis_zsets (key, member, score) VALUES (?, ?, ?)'
|
|
31
|
+
);
|
|
32
|
+
const updateScoreStmt = db.prepare(
|
|
33
|
+
'UPDATE redis_zsets SET score = ? WHERE key = ? AND member = ?'
|
|
34
|
+
);
|
|
29
35
|
const deleteStmt = db.prepare('DELETE FROM redis_zsets WHERE key = ? AND member = ?');
|
|
30
36
|
const deleteAllStmt = db.prepare('DELETE FROM redis_zsets WHERE key = ?');
|
|
31
37
|
const countStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_zsets WHERE key = ?');
|
|
@@ -82,19 +88,40 @@ export function createZsetsStorage(db, keys) {
|
|
|
82
88
|
return runInTransaction(db, () => {
|
|
83
89
|
const now = options.updatedAt ?? Date.now();
|
|
84
90
|
const meta = keys.get(key);
|
|
91
|
+
let knownCount = 0;
|
|
85
92
|
if (meta) {
|
|
86
93
|
if (meta.type !== KEY_TYPES.ZSET) {
|
|
87
94
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
88
95
|
}
|
|
89
96
|
keys.bumpVersion(key);
|
|
97
|
+
if (meta.zsetCount == null) {
|
|
98
|
+
const row = countStmt.get(key);
|
|
99
|
+
knownCount = (row && row.n) || 0;
|
|
100
|
+
keys.setZsetCount(key, knownCount, { touchUpdatedAt: false });
|
|
101
|
+
} else {
|
|
102
|
+
knownCount = meta.zsetCount;
|
|
103
|
+
}
|
|
90
104
|
} else {
|
|
91
|
-
keys.set(key, KEY_TYPES.ZSET, { updatedAt: now });
|
|
105
|
+
keys.set(key, KEY_TYPES.ZSET, { updatedAt: now, zsetCount: 0 });
|
|
92
106
|
}
|
|
93
107
|
let newCount = 0;
|
|
94
108
|
for (const { score, member } of pairs) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
const inserted = insertIgnoreStmt.run(key, member, score).changes;
|
|
110
|
+
if (inserted > 0) {
|
|
111
|
+
newCount++;
|
|
112
|
+
} else {
|
|
113
|
+
updateScoreStmt.run(score, key, member);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (newCount > 0) {
|
|
117
|
+
if (meta) {
|
|
118
|
+
keys.incrZsetCount(key, newCount, { touchUpdatedAt: false });
|
|
119
|
+
} else {
|
|
120
|
+
keys.setZsetCount(key, newCount, { touchUpdatedAt: false });
|
|
121
|
+
}
|
|
122
|
+
} else if (meta && meta.zsetCount == null) {
|
|
123
|
+
// Legacy rows may have null counters; persist the hydrated value.
|
|
124
|
+
keys.setZsetCount(key, knownCount, { touchUpdatedAt: false });
|
|
98
125
|
}
|
|
99
126
|
return newCount;
|
|
100
127
|
});
|
|
@@ -108,23 +135,35 @@ export function createZsetsStorage(db, keys) {
|
|
|
108
135
|
*/
|
|
109
136
|
remove(key, members) {
|
|
110
137
|
return runInTransaction(db, () => {
|
|
138
|
+
const meta = keys.get(key);
|
|
139
|
+
const before = meta && meta.zsetCount != null ? meta.zsetCount : null;
|
|
111
140
|
let n = 0;
|
|
112
141
|
for (const m of members) {
|
|
113
142
|
n += deleteStmt.run(key, m).changes;
|
|
114
143
|
}
|
|
115
|
-
const
|
|
116
|
-
const remaining = (row && row.n) || 0;
|
|
144
|
+
const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n || 0);
|
|
117
145
|
if (remaining === 0) {
|
|
118
146
|
deleteAllStmt.run(key);
|
|
119
147
|
keys.delete(key);
|
|
148
|
+
} else if (n > 0) {
|
|
149
|
+
keys.setZsetCount(key, remaining, { touchUpdatedAt: false });
|
|
120
150
|
}
|
|
121
151
|
return n;
|
|
122
152
|
});
|
|
123
153
|
},
|
|
124
154
|
|
|
125
155
|
count(key) {
|
|
156
|
+
const meta = keys.get(key);
|
|
157
|
+
if (meta && meta.type === KEY_TYPES.ZSET && meta.zsetCount != null) {
|
|
158
|
+
return meta.zsetCount;
|
|
159
|
+
}
|
|
126
160
|
const row = countStmt.get(key);
|
|
127
|
-
|
|
161
|
+
const n = row ? row.n : 0;
|
|
162
|
+
if (meta && meta.type === KEY_TYPES.ZSET && meta.zsetCount == null) {
|
|
163
|
+
// One-time hydration for databases created before zset_count existed.
|
|
164
|
+
keys.setZsetCount(key, n, { touchUpdatedAt: false });
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
128
167
|
},
|
|
129
168
|
|
|
130
169
|
score(key, member) {
|
|
@@ -270,6 +309,9 @@ export function createZsetsStorage(db, keys) {
|
|
|
270
309
|
for (const r of rows) {
|
|
271
310
|
upsertStmt.run(newKey, r.member, r.score);
|
|
272
311
|
}
|
|
312
|
+
const sourceMeta = keys.get(oldKey);
|
|
313
|
+
const nextCount = sourceMeta && sourceMeta.zsetCount != null ? sourceMeta.zsetCount : rows.length;
|
|
314
|
+
keys.setZsetCount(newKey, nextCount, { touchUpdatedAt: false });
|
|
273
315
|
},
|
|
274
316
|
|
|
275
317
|
countByScore(key, min, max) {
|
|
@@ -285,14 +327,23 @@ export function createZsetsStorage(db, keys) {
|
|
|
285
327
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
286
328
|
}
|
|
287
329
|
if (!meta) {
|
|
288
|
-
keys.set(key, KEY_TYPES.ZSET, { updatedAt: now });
|
|
330
|
+
keys.set(key, KEY_TYPES.ZSET, { updatedAt: now, zsetCount: 0 });
|
|
289
331
|
} else {
|
|
290
332
|
keys.bumpVersion(key);
|
|
333
|
+
if (meta.zsetCount == null) {
|
|
334
|
+
const row = countStmt.get(key);
|
|
335
|
+
const hydrated = (row && row.n) || 0;
|
|
336
|
+
keys.setZsetCount(key, hydrated, { touchUpdatedAt: false });
|
|
337
|
+
}
|
|
291
338
|
}
|
|
292
339
|
const cur = scoreStmt.get(key, member);
|
|
293
340
|
const prev = cur == null ? 0 : cur.score;
|
|
294
341
|
const next = prev + increment;
|
|
295
342
|
upsertStmt.run(key, member, next);
|
|
343
|
+
if (cur == null) {
|
|
344
|
+
if (meta) keys.incrZsetCount(key, 1, { touchUpdatedAt: false });
|
|
345
|
+
else keys.setZsetCount(key, 1, { touchUpdatedAt: false });
|
|
346
|
+
}
|
|
296
347
|
return formatScore(next);
|
|
297
348
|
});
|
|
298
349
|
},
|
|
@@ -318,6 +369,8 @@ export function createZsetsStorage(db, keys) {
|
|
|
318
369
|
if (remaining === 0) {
|
|
319
370
|
deleteAllStmt.run(key);
|
|
320
371
|
keys.delete(key);
|
|
372
|
+
} else if (n > 0) {
|
|
373
|
+
keys.setZsetCount(key, remaining, { touchUpdatedAt: false });
|
|
321
374
|
}
|
|
322
375
|
return n;
|
|
323
376
|
});
|
|
@@ -333,6 +386,7 @@ export function createZsetsStorage(db, keys) {
|
|
|
333
386
|
keys.delete(key);
|
|
334
387
|
} else if (r.changes > 0) {
|
|
335
388
|
keys.bumpVersion(key);
|
|
389
|
+
keys.setZsetCount(key, n, { touchUpdatedAt: false });
|
|
336
390
|
}
|
|
337
391
|
return r.changes;
|
|
338
392
|
});
|
|
@@ -89,6 +89,17 @@ describe('createRESPlite', () => {
|
|
|
89
89
|
await srv.close();
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it('accepts pragma overrides (convention: template first, overrides only when needed)', async () => {
|
|
93
|
+
const srv = await createRESPlite({
|
|
94
|
+
pragma: { synchronous: 'FULL', cache_size: -10_000 },
|
|
95
|
+
});
|
|
96
|
+
const client = await redisClient(srv.port);
|
|
97
|
+
await client.set('k', 'v');
|
|
98
|
+
assert.equal(await client.get('k'), 'v');
|
|
99
|
+
await client.quit();
|
|
100
|
+
await srv.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
92
103
|
it('unsupported command still returns ERR command not supported yet to client', async () => {
|
|
93
104
|
const srv = await createRESPlite();
|
|
94
105
|
const client = await redisClient(srv.port);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, before, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
3
4
|
import { createTestServer } from '../helpers/server.js';
|
|
4
5
|
import { sendCommand, argv } from '../helpers/client.js';
|
|
5
6
|
import { tryParseValue } from '../../src/resp/parser.js';
|
|
@@ -56,4 +57,33 @@ describe('Hashes integration', () => {
|
|
|
56
57
|
const reply = await sendCommand(port, argv('HLEN', 'hlen:str'));
|
|
57
58
|
assert.ok(reply.toString('utf8').includes('WRONGTYPE'));
|
|
58
59
|
});
|
|
60
|
+
|
|
61
|
+
it('RENAME keeps hash cardinality metadata', async () => {
|
|
62
|
+
await sendCommand(port, argv('HSET', 'hrename:src', 'a', '1', 'b', '2', 'c', '3'));
|
|
63
|
+
await sendCommand(port, argv('RENAME', 'hrename:src', 'hrename:dst'));
|
|
64
|
+
const reply = await sendCommand(port, argv('HLEN', 'hrename:dst'));
|
|
65
|
+
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('legacy hash rows with null hash_count hydrate on first HLEN', async () => {
|
|
69
|
+
const s1 = await createTestServer();
|
|
70
|
+
await sendCommand(s1.port, argv('HSET', 'legacy:h', 'f1', 'v1', 'f2', 'v2'));
|
|
71
|
+
const dbPath = s1.dbPath;
|
|
72
|
+
await s1.closeAsync();
|
|
73
|
+
s1.db.close();
|
|
74
|
+
|
|
75
|
+
const legacyDb = new Database(dbPath);
|
|
76
|
+
legacyDb.prepare('UPDATE redis_keys SET hash_count = NULL WHERE key = ?').run(Buffer.from('legacy:h', 'utf8'));
|
|
77
|
+
legacyDb.close();
|
|
78
|
+
|
|
79
|
+
const s2 = await createTestServer({ dbPath });
|
|
80
|
+
const first = await sendCommand(s2.port, argv('HLEN', 'legacy:h'));
|
|
81
|
+
assert.equal(tryParseValue(first, 0).value, 2);
|
|
82
|
+
const second = await sendCommand(s2.port, argv('HLEN', 'legacy:h'));
|
|
83
|
+
assert.equal(tryParseValue(second, 0).value, 2);
|
|
84
|
+
|
|
85
|
+
const row = s2.db.prepare('SELECT hash_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy:h', 'utf8'));
|
|
86
|
+
assert.equal(row.n, 2);
|
|
87
|
+
await s2.closeAsync();
|
|
88
|
+
});
|
|
59
89
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, before, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
3
4
|
import { createTestServer } from '../helpers/server.js';
|
|
4
5
|
import { sendCommand, argv } from '../helpers/client.js';
|
|
5
6
|
import { tryParseValue } from '../../src/resp/parser.js';
|
|
@@ -40,6 +41,15 @@ describe('ZSET integration', () => {
|
|
|
40
41
|
assert.equal(score.toString('utf8'), '20');
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
it('ZADD with duplicate member in same command counts as new once and keeps last score', async () => {
|
|
45
|
+
const added = await sendCommand(port, argv('ZADD', 'zdup', '1', 'm', '2', 'm'));
|
|
46
|
+
assert.equal(tryParseValue(added, 0).value, 1);
|
|
47
|
+
|
|
48
|
+
const scoreReply = await sendCommand(port, argv('ZSCORE', 'zdup', 'm'));
|
|
49
|
+
const score = tryParseValue(scoreReply, 0).value;
|
|
50
|
+
assert.equal(score.toString('utf8'), '2');
|
|
51
|
+
});
|
|
52
|
+
|
|
43
53
|
it('ZRANGE WITHSCORES returns member, score, ...', async () => {
|
|
44
54
|
await sendCommand(port, argv('ZADD', 'z3', '1', 'x', '2', 'y'));
|
|
45
55
|
const reply = await sendCommand(port, argv('ZRANGE', 'z3', '0', '-1', 'WITHSCORES'));
|
|
@@ -201,6 +211,35 @@ describe('ZSET integration', () => {
|
|
|
201
211
|
await s2.closeAsync();
|
|
202
212
|
});
|
|
203
213
|
|
|
214
|
+
it('RENAME keeps zset cardinality metadata', async () => {
|
|
215
|
+
await sendCommand(port, argv('ZADD', 'zrename_src', '1', 'a', '2', 'b', '3', 'c'));
|
|
216
|
+
await sendCommand(port, argv('RENAME', 'zrename_src', 'zrename_dst'));
|
|
217
|
+
const cardReply = await sendCommand(port, argv('ZCARD', 'zrename_dst'));
|
|
218
|
+
assert.equal(tryParseValue(cardReply, 0).value, 3);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('legacy zset rows with null zset_count hydrate on first ZCARD', async () => {
|
|
222
|
+
const s1 = await createTestServer();
|
|
223
|
+
await sendCommand(s1.port, argv('ZADD', 'legacy_z', '1', 'one', '2', 'two'));
|
|
224
|
+
const dbPath = s1.dbPath;
|
|
225
|
+
await s1.closeAsync();
|
|
226
|
+
s1.db.close();
|
|
227
|
+
|
|
228
|
+
const legacyDb = new Database(dbPath);
|
|
229
|
+
legacyDb.prepare('UPDATE redis_keys SET zset_count = NULL WHERE key = ?').run(Buffer.from('legacy_z', 'utf8'));
|
|
230
|
+
legacyDb.close();
|
|
231
|
+
|
|
232
|
+
const s2 = await createTestServer({ dbPath });
|
|
233
|
+
const first = await sendCommand(s2.port, argv('ZCARD', 'legacy_z'));
|
|
234
|
+
assert.equal(tryParseValue(first, 0).value, 2);
|
|
235
|
+
const second = await sendCommand(s2.port, argv('ZCARD', 'legacy_z'));
|
|
236
|
+
assert.equal(tryParseValue(second, 0).value, 2);
|
|
237
|
+
|
|
238
|
+
const row = s2.db.prepare('SELECT zset_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy_z', 'utf8'));
|
|
239
|
+
assert.equal(row.n, 2);
|
|
240
|
+
await s2.closeAsync();
|
|
241
|
+
});
|
|
242
|
+
|
|
204
243
|
it('binary-safe zset members', async () => {
|
|
205
244
|
const bin = Buffer.from([0x00, 0xff]);
|
|
206
245
|
await sendCommand(port, argv('ZADD', 'zbin', '1', 'normal'));
|
|
@@ -72,4 +72,28 @@ describe('Pragma templates', () => {
|
|
|
72
72
|
db.close();
|
|
73
73
|
}
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
it('openDb with pragma overrides applies them after the template', () => {
|
|
77
|
+
const path = tmpDbPath();
|
|
78
|
+
const db = openDb(path, { pragmaTemplate: 'default', pragma: { synchronous: 'FULL' } });
|
|
79
|
+
try {
|
|
80
|
+
const row = db.prepare('PRAGMA synchronous').get();
|
|
81
|
+
assert.equal(row.synchronous, 2); // FULL = 2
|
|
82
|
+
} finally {
|
|
83
|
+
db.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('openDb with pragma cache_size override (e.g. 1 GiB)', () => {
|
|
88
|
+
const path = tmpDbPath();
|
|
89
|
+
const oneGibKib = 1024 * 1024;
|
|
90
|
+
const db = openDb(path, { pragmaTemplate: 'default', pragma: { cache_size: -oneGibKib } });
|
|
91
|
+
try {
|
|
92
|
+
const row = db.prepare('PRAGMA cache_size').get();
|
|
93
|
+
// SQLite returns cache size in KiB when it was set negative
|
|
94
|
+
assert.equal(Math.abs(row.cache_size), oneGibKib);
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
75
99
|
});
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: 90dhbsvf0f
|
|
3
|
-
type: implementation
|
|
4
|
-
title: Apply dirty migration concurrency and progress
|
|
5
|
-
created: '2026-03-12 14:55:12'
|
|
6
|
-
---
|
|
7
|
-
Improved src/migration/apply-dirty.js to support concurrent dirty-key apply via options.concurrency (chunked Promise.all worker model) while preserving max_rps throttling. Added richer onProgress payload fields: dirty_keys_processed, dirty_pending, dirty_keys_per_second, dirty_eta_seconds, and related counters. Exposed new options through createMigration().applyDirty: concurrency and progressIntervalMs. Updated README migration cutover snippet with high-throughput applyDirty example and progress logging. Added unit test test/unit/migration-apply-dirty.test.js validating concurrency and progress payload. Verified with node --test test/unit/migration-apply-dirty.test.js and npm run test:unit (all passing).
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: tucj9i5nh5
|
|
3
|
-
type: implementation
|
|
4
|
-
title: Bulk migration concurrency added
|
|
5
|
-
created: '2026-03-11 11:09:20'
|
|
6
|
-
---
|
|
7
|
-
Added configurable concurrency to runBulkImport and createMigration.bulk with default 1. Implemented chunked parallel import with shared global max_rps limiter. Added unit tests proving default sequential behavior and concurrent behavior with cap.
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
id: 105jsp012x
|
|
3
|
-
type: implementation
|
|
4
|
-
title: Bulk onProgress ETA support
|
|
5
|
-
created: '2026-03-11 11:10:48'
|
|
6
|
-
---
|
|
7
|
-
Added ETA/progress metrics to bulk migration onProgress payload. New optional options: estimated_total_keys in runBulkImport, estimatedTotalKeys in createMigration/bulk(). onProgress payload now includes elapsed_seconds, keys_per_second, estimated_total_keys, remaining_keys_estimate, eta_seconds, progress_pct. README migration example updated to print ETA/rate. Added unit test validating ETA fields and final 100%/eta=0 behavior.
|