resplite 1.0.2 → 1.0.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Bulk import from Redis with checkpointing, resume, and throttling (SPEC_F §F.7).
3
+ */
4
+
5
+ import { openDb } from '../storage/sqlite/db.js';
6
+ import { createKeysStorage } from '../storage/sqlite/keys.js';
7
+ import { createStringsStorage } from '../storage/sqlite/strings.js';
8
+ import { createHashesStorage } from '../storage/sqlite/hashes.js';
9
+ import { createSetsStorage } from '../storage/sqlite/sets.js';
10
+ import { createListsStorage } from '../storage/sqlite/lists.js';
11
+ import { createZsetsStorage } from '../storage/sqlite/zsets.js';
12
+ import {
13
+ createRun,
14
+ getRun,
15
+ updateBulkProgress,
16
+ setRunStatus,
17
+ logError,
18
+ RUN_STATUS,
19
+ } from './registry.js';
20
+ import { importKeyFromRedis } from './import-one.js';
21
+
22
+ function parseScanResult(result) {
23
+ if (Array.isArray(result)) {
24
+ return { cursor: parseInt(result[0], 10), keys: result[1] || [] };
25
+ }
26
+ if (result && typeof result === 'object') {
27
+ const cursor = typeof result.cursor === 'number' ? result.cursor : parseInt(String(result.cursor), 10);
28
+ const keys = result.keys || [];
29
+ return { cursor, keys };
30
+ }
31
+ return { cursor: 0, keys: [] };
32
+ }
33
+
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ /**
39
+ * Run bulk import: SCAN keys from Redis, import into RespLite DB with checkpointing.
40
+ * @param {import('redis').RedisClientType} redisClient
41
+ * @param {string} dbPath
42
+ * @param {string} runId
43
+ * @param {object} options
44
+ * @param {string} options.sourceUri
45
+ * @param {string} [options.pragmaTemplate='default']
46
+ * @param {number} [options.scan_count=1000]
47
+ * @param {number} [options.max_rps=0] - 0 = no limit
48
+ * @param {number} [options.batch_keys=200]
49
+ * @param {number} [options.batch_bytes=64*1024*1024] - 64MB
50
+ * @param {number} [options.checkpoint_interval_sec=30]
51
+ * @param {boolean} [options.resume=false]
52
+ * @param {function(run): void} [options.onProgress] - called after checkpoint with run row
53
+ */
54
+ export async function runBulkImport(redisClient, dbPath, runId, options = {}) {
55
+ const {
56
+ sourceUri,
57
+ pragmaTemplate = 'default',
58
+ scan_count = 1000,
59
+ max_rps = 0,
60
+ batch_keys = 200,
61
+ batch_bytes = 64 * 1024 * 1024,
62
+ checkpoint_interval_sec = 30,
63
+ resume = false,
64
+ onProgress,
65
+ } = options;
66
+
67
+ const db = openDb(dbPath, { pragmaTemplate });
68
+ const keys = createKeysStorage(db);
69
+ const strings = createStringsStorage(db, keys);
70
+ const hashes = createHashesStorage(db, keys);
71
+ const sets = createSetsStorage(db, keys);
72
+ const lists = createListsStorage(db, keys);
73
+ const zsets = createZsetsStorage(db, keys);
74
+ const storages = { keys, strings, hashes, sets, lists, zsets };
75
+
76
+ createRun(db, runId, sourceUri, { scan_count_hint: scan_count });
77
+ let run = getRun(db, runId);
78
+ if (!run) throw new Error(`Run ${runId} not found`);
79
+
80
+ let cursor = resume && run.scan_cursor !== undefined ? parseInt(String(run.scan_cursor), 10) : 0;
81
+ let scanned_keys = resume ? (run.scanned_keys || 0) : 0;
82
+ let migrated_keys = resume ? (run.migrated_keys || 0) : 0;
83
+ let skipped_keys = resume ? (run.skipped_keys || 0) : 0;
84
+ let error_keys = resume ? (run.error_keys || 0) : 0;
85
+ let migrated_bytes = resume ? (run.migrated_bytes || 0) : 0;
86
+
87
+ if (!resume) {
88
+ updateBulkProgress(db, runId, { scan_cursor: String(cursor), scanned_keys, migrated_keys, skipped_keys, error_keys, migrated_bytes });
89
+ }
90
+
91
+ let lastCheckpointTime = Date.now();
92
+ let batchScanned = 0;
93
+ let batchBytes = 0;
94
+ const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
95
+ let lastKeyTime = 0;
96
+
97
+ try {
98
+ do {
99
+ run = getRun(db, runId);
100
+ if (run && run.status === RUN_STATUS.ABORTED) {
101
+ break;
102
+ }
103
+ while (run && run.status === RUN_STATUS.PAUSED) {
104
+ await sleep(2000);
105
+ run = getRun(db, runId);
106
+ }
107
+
108
+ const result = await redisClient.scan(cursor, { COUNT: scan_count });
109
+ const parsed = parseScanResult(result);
110
+ cursor = parsed.cursor;
111
+ const keyList = parsed.keys || [];
112
+
113
+ for (const keyName of keyList) {
114
+ run = getRun(db, runId);
115
+ if (run && run.status === RUN_STATUS.ABORTED) break;
116
+ while (run && run.status === RUN_STATUS.PAUSED) {
117
+ await sleep(2000);
118
+ run = getRun(db, runId);
119
+ }
120
+
121
+ scanned_keys++;
122
+ if (minIntervalMs > 0) {
123
+ const elapsed = Date.now() - lastKeyTime;
124
+ if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
125
+ lastKeyTime = Date.now();
126
+ }
127
+
128
+ const now = Date.now();
129
+ const outcome = await importKeyFromRedis(redisClient, keyName, storages, { now });
130
+ if (outcome.ok) {
131
+ migrated_keys++;
132
+ migrated_bytes += outcome.bytes || 0;
133
+ batchScanned++;
134
+ batchBytes += outcome.bytes || 0;
135
+ } else if (outcome.skipped) {
136
+ skipped_keys++;
137
+ } else {
138
+ error_keys++;
139
+ logError(db, runId, 'bulk', outcome.error ? 'Import failed' : 'Skipped', keyName);
140
+ }
141
+
142
+ const now2 = Date.now();
143
+ const shouldCheckpoint =
144
+ batchScanned >= batch_keys ||
145
+ batchBytes >= batch_bytes ||
146
+ now2 - lastCheckpointTime >= checkpoint_interval_sec * 1000;
147
+ if (shouldCheckpoint) {
148
+ updateBulkProgress(db, runId, {
149
+ scan_cursor: String(cursor),
150
+ scanned_keys,
151
+ migrated_keys,
152
+ skipped_keys,
153
+ error_keys,
154
+ migrated_bytes,
155
+ });
156
+ lastCheckpointTime = now2;
157
+ batchScanned = 0;
158
+ batchBytes = 0;
159
+ run = getRun(db, runId);
160
+ if (onProgress && run) onProgress(run);
161
+ }
162
+ }
163
+ } while (cursor !== 0);
164
+
165
+ updateBulkProgress(db, runId, {
166
+ scan_cursor: '0',
167
+ scanned_keys,
168
+ migrated_keys,
169
+ skipped_keys,
170
+ error_keys,
171
+ migrated_bytes,
172
+ });
173
+ setRunStatus(db, runId, RUN_STATUS.COMPLETED);
174
+ return getRun(db, runId);
175
+ } catch (err) {
176
+ setRunStatus(db, runId, RUN_STATUS.FAILED);
177
+ updateBulkProgress(db, runId, { last_error: err.message });
178
+ logError(db, runId, 'bulk', err.message, null);
179
+ throw err;
180
+ }
181
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Import a single key from Redis into RespLite storages (shared by bulk and delta apply).
3
+ * SPEC_F §F.7.1, F.8.2.
4
+ */
5
+
6
+ import { asKey, asValue } from '../util/buffers.js';
7
+
8
+ const SUPPORTED_TYPES = new Set(['string', 'hash', 'set', 'list', 'zset']);
9
+
10
+ function toBuffer(value) {
11
+ if (value == null) return null;
12
+ if (Buffer.isBuffer(value)) return value;
13
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
14
+ return Buffer.from(String(value), 'utf8');
15
+ }
16
+
17
+ /**
18
+ * Fetch one key from Redis and write to storages. Idempotent (upsert).
19
+ * @param {import('redis').RedisClientType} redisClient
20
+ * @param {string} keyName
21
+ * @param {{ keys: import('../storage/sqlite/keys.js').ReturnType<import('../storage/sqlite/keys.js').createKeysStorage>; strings: ReturnType<import('../storage/sqlite/strings.js').createStringsStorage>; hashes: ReturnType<import('../storage/sqlite/hashes.js').createHashesStorage>; sets: ReturnType<import('../storage/sqlite/sets.js').createSetsStorage>; lists: ReturnType<import('../storage/sqlite/lists.js').createListsStorage>; zsets: ReturnType<import('../storage/sqlite/zsets.js').createZsetsStorage> }} storages
22
+ * @param {{ now?: number }} options
23
+ * @returns {Promise<{ ok: boolean; skipped?: boolean; error?: boolean; bytes?: number }>}
24
+ */
25
+ export async function importKeyFromRedis(redisClient, keyName, storages, options = {}) {
26
+ const now = options.now ?? Date.now();
27
+ const { keys, strings, hashes, sets, lists, zsets } = storages;
28
+
29
+ try {
30
+ const type = (await redisClient.type(keyName)).toLowerCase();
31
+ if (!SUPPORTED_TYPES.has(type)) {
32
+ return { ok: false, skipped: true };
33
+ }
34
+
35
+ let pttl = await redisClient.pTTL(keyName);
36
+ if (pttl === -2) pttl = -1;
37
+ const expiresAt = pttl > 0 ? now + pttl : null;
38
+ const keyBuf = asKey(keyName);
39
+
40
+ let bytes = keyBuf.length;
41
+
42
+ if (type === 'string') {
43
+ const value = await redisClient.get(keyName);
44
+ if (value === undefined || value === null) return { ok: false, skipped: true };
45
+ const valBuf = asValue(value);
46
+ bytes += valBuf.length;
47
+ strings.set(keyBuf, valBuf, { expiresAt, updatedAt: now });
48
+ return { ok: true, bytes };
49
+ }
50
+
51
+ if (type === 'hash') {
52
+ const obj = await redisClient.hGetAll(keyName);
53
+ if (!obj || typeof obj !== 'object') return { ok: false, skipped: true };
54
+ const pairs = [];
55
+ for (const [f, v] of Object.entries(obj)) {
56
+ const fb = toBuffer(f);
57
+ const vb = toBuffer(v);
58
+ pairs.push(fb, vb);
59
+ bytes += fb.length + vb.length;
60
+ }
61
+ if (pairs.length === 0) return { ok: false, skipped: true };
62
+ hashes.setMultiple(keyBuf, pairs, { updatedAt: now });
63
+ keys.setExpires(keyBuf, expiresAt, now);
64
+ return { ok: true, bytes };
65
+ }
66
+
67
+ if (type === 'set') {
68
+ const members = await redisClient.sMembers(keyName);
69
+ if (!members || !members.length) return { ok: false, skipped: true };
70
+ const memberBuffers = members.map((m) => toBuffer(m));
71
+ for (const b of memberBuffers) bytes += b.length;
72
+ sets.add(keyBuf, memberBuffers, { updatedAt: now });
73
+ keys.setExpires(keyBuf, expiresAt, now);
74
+ return { ok: true, bytes };
75
+ }
76
+
77
+ if (type === 'list') {
78
+ const elements = await redisClient.lRange(keyName, 0, -1);
79
+ if (!elements || !elements.length) return { ok: false, skipped: true };
80
+ const valueBuffers = elements.map((e) => toBuffer(e));
81
+ for (const b of valueBuffers) bytes += b.length;
82
+ lists.rpush(keyBuf, valueBuffers, { updatedAt: now });
83
+ keys.setExpires(keyBuf, expiresAt, now);
84
+ return { ok: true, bytes };
85
+ }
86
+
87
+ if (type === 'zset') {
88
+ const withScores = await redisClient.zRangeWithScores(keyName, 0, -1);
89
+ if (!withScores || !withScores.length) return { ok: false, skipped: true };
90
+ const pairs = withScores.map((item) => ({
91
+ member: toBuffer(item.value),
92
+ score: Number(item.score),
93
+ }));
94
+ for (const p of pairs) bytes += p.member.length + 8;
95
+ zsets.add(keyBuf, pairs, { updatedAt: now });
96
+ keys.setExpires(keyBuf, expiresAt, now);
97
+ return { ok: true, bytes };
98
+ }
99
+
100
+ return { ok: false, skipped: true };
101
+ } catch (err) {
102
+ return { ok: false, error: true };
103
+ }
104
+ }
105
+
106
+ export { SUPPORTED_TYPES };
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Programmatic migration API (SPEC_F §F.9).
3
+ *
4
+ * Usage:
5
+ * import { createMigration } from 'resplite/migration';
6
+ *
7
+ * const m = createMigration({
8
+ * from: 'redis://127.0.0.1:6379',
9
+ * to: './data.db',
10
+ * runId: 'my-migration-1',
11
+ * });
12
+ *
13
+ * const info = await m.preflight();
14
+ * await m.bulk({ onProgress: console.log });
15
+ * const status = m.status();
16
+ * await m.applyDirty();
17
+ * const result = await m.verify();
18
+ * await m.close();
19
+ */
20
+
21
+ import { createClient } from 'redis';
22
+ import { openDb } from '../storage/sqlite/db.js';
23
+ import { runPreflight } from './preflight.js';
24
+ import { runBulkImport } from './bulk.js';
25
+ import { runApplyDirty } from './apply-dirty.js';
26
+ import { runVerify } from './verify.js';
27
+ import { getRun, getDirtyCounts } from './registry.js';
28
+
29
+ /**
30
+ * @typedef {object} MigrationOptions
31
+ * @property {string} [from='redis://127.0.0.1:6379'] - Source Redis URL.
32
+ * @property {string} to - Destination SQLite DB path.
33
+ * @property {string} [runId] - Unique run identifier (required for bulk/status/applyDirty).
34
+ * @property {string} [pragmaTemplate='default'] - PRAGMA preset.
35
+ * @property {number} [scanCount=1000]
36
+ * @property {number} [maxRps=0] - Max requests/s (0 = unlimited).
37
+ * @property {number} [batchKeys=200]
38
+ * @property {number} [batchBytes=67108864] - 64 MB default.
39
+ */
40
+
41
+ /**
42
+ * Create a migration controller bound to a source Redis and destination DB.
43
+ *
44
+ * @param {MigrationOptions} options
45
+ * @returns {{
46
+ * preflight(): Promise<import('./preflight.js').PreflightResult>,
47
+ * bulk(opts?: { resume?: boolean, onProgress?: function }): Promise<object>,
48
+ * status(): { run: object, dirty: object } | null,
49
+ * applyDirty(opts?: { batchKeys?: number, maxRps?: number }): Promise<object>,
50
+ * verify(opts?: { samplePct?: number, maxSample?: number }): Promise<object>,
51
+ * close(): Promise<void>,
52
+ * }}
53
+ */
54
+ export function createMigration({
55
+ from = 'redis://127.0.0.1:6379',
56
+ to,
57
+ runId,
58
+ pragmaTemplate = 'default',
59
+ scanCount = 1000,
60
+ maxRps = 0,
61
+ batchKeys = 200,
62
+ batchBytes = 64 * 1024 * 1024,
63
+ } = {}) {
64
+ if (!to) throw new Error('createMigration: "to" (db path) is required');
65
+
66
+ let _client = null;
67
+
68
+ async function getClient() {
69
+ if (_client) return _client;
70
+ _client = createClient({ url: from });
71
+ _client.on('error', (err) => {
72
+ /* non-fatal connection errors; callers surface them on next await */
73
+ void err;
74
+ });
75
+ await _client.connect();
76
+ return _client;
77
+ }
78
+
79
+ function requireRunId() {
80
+ if (!runId) throw new Error('createMigration: "runId" is required for this operation');
81
+ return runId;
82
+ }
83
+
84
+ return {
85
+ /**
86
+ * Step 0 — Preflight: inspect the source Redis instance.
87
+ * Returns key count estimate, type distribution, keyspace-events config,
88
+ * and recommended import parameters.
89
+ */
90
+ async preflight() {
91
+ const client = await getClient();
92
+ return runPreflight(client);
93
+ },
94
+
95
+ /**
96
+ * Step 1 — Bulk import: SCAN all keys from Redis into the destination DB.
97
+ * Supports resume (checkpoint-based) and optional progress callback.
98
+ *
99
+ * @param {{ resume?: boolean, onProgress?: (run: object) => void }} [opts]
100
+ */
101
+ async bulk({ resume = false, onProgress } = {}) {
102
+ const id = requireRunId();
103
+ const client = await getClient();
104
+ return runBulkImport(client, to, id, {
105
+ sourceUri: from,
106
+ pragmaTemplate,
107
+ scan_count: scanCount,
108
+ max_rps: maxRps,
109
+ batch_keys: batchKeys,
110
+ batch_bytes: batchBytes,
111
+ resume,
112
+ onProgress,
113
+ });
114
+ },
115
+
116
+ /**
117
+ * Step 2 — Status: read run metadata and dirty-key counts from the DB.
118
+ * Synchronous — no Redis connection needed.
119
+ *
120
+ * @returns {{ run: object, dirty: object } | null}
121
+ */
122
+ status() {
123
+ const id = requireRunId();
124
+ const db = openDb(to, { pragmaTemplate });
125
+ const run = getRun(db, id);
126
+ if (!run) return null;
127
+ const dirty = getDirtyCounts(db, id);
128
+ return { run, dirty };
129
+ },
130
+
131
+ /**
132
+ * Step 3 — Apply dirty: reconcile keys that changed in Redis during bulk import.
133
+ *
134
+ * @param {{ batchKeys?: number, maxRps?: number }} [opts]
135
+ */
136
+ async applyDirty({ batchKeys: bk = batchKeys, maxRps: rps = maxRps } = {}) {
137
+ const id = requireRunId();
138
+ const client = await getClient();
139
+ return runApplyDirty(client, to, id, {
140
+ pragmaTemplate,
141
+ batch_keys: bk,
142
+ max_rps: rps,
143
+ });
144
+ },
145
+
146
+ /**
147
+ * Step 4 — Verify: sample keys from Redis and compare with the destination DB.
148
+ *
149
+ * @param {{ samplePct?: number, maxSample?: number }} [opts]
150
+ * @returns {Promise<{ sampled: number, matched: number, mismatches: Array<{ key: string, reason: string }> }>}
151
+ */
152
+ async verify({ samplePct = 0.5, maxSample = 10000 } = {}) {
153
+ const client = await getClient();
154
+ return runVerify(client, to, { pragmaTemplate, samplePct, maxSample });
155
+ },
156
+
157
+ /**
158
+ * Disconnect from Redis. Call when done with all migration operations.
159
+ */
160
+ async close() {
161
+ if (_client) {
162
+ await _client.quit().catch(() => {});
163
+ _client = null;
164
+ }
165
+ },
166
+ };
167
+ }
168
+
169
+ export { runPreflight, runBulkImport, runApplyDirty, runVerify };
170
+ export { getRun, getDirtyCounts, createRun, setRunStatus, logError } from './registry.js';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Preflight check: estimate key count, type distribution, notify-keyspace-events (SPEC_F §F.9 Step 0).
3
+ */
4
+
5
+ /**
6
+ * @param {import('redis').RedisClientType} redisClient
7
+ * @returns {Promise<{ keyCountEstimate: number; typeDistribution: Record<string, number>; notifyKeyspaceEvents: string | null; recommended: { scan_count: number; max_concurrency: number; max_rps: number } }>}
8
+ */
9
+ export async function runPreflight(redisClient) {
10
+ let keyCountEstimate = 0;
11
+ try {
12
+ keyCountEstimate = await redisClient.dbSize();
13
+ } catch (_) {
14
+ keyCountEstimate = 0;
15
+ }
16
+
17
+ const typeDistribution = { string: 0, hash: 0, set: 0, list: 0, zset: 0, other: 0 };
18
+ const sampleSize = Math.min(1000, Math.max(100, Math.floor(keyCountEstimate / 10)));
19
+ let cursor = 0;
20
+ let sampled = 0;
21
+
22
+ if (keyCountEstimate > 0) {
23
+ do {
24
+ const result = await redisClient.scan(cursor, { COUNT: 200 });
25
+ const keys = Array.isArray(result) ? result[1] : (result?.keys || []);
26
+ cursor = Array.isArray(result) ? parseInt(result[0], 10) : (result?.cursor ?? 0);
27
+
28
+ for (const key of keys) {
29
+ if (sampled >= sampleSize) break;
30
+ try {
31
+ const type = (await redisClient.type(key)).toLowerCase();
32
+ if (typeDistribution[type] !== undefined) typeDistribution[type]++;
33
+ else typeDistribution.other++;
34
+ sampled++;
35
+ } catch (_) {}
36
+ }
37
+ if (sampled >= sampleSize) break;
38
+ } while (cursor !== 0);
39
+ }
40
+
41
+ let notifyKeyspaceEvents = null;
42
+ try {
43
+ const config = await redisClient.configGet('notify-keyspace-events');
44
+ notifyKeyspaceEvents = config && typeof config === 'object' ? config['notify-keyspace-events'] : null;
45
+ if (typeof notifyKeyspaceEvents !== 'string') notifyKeyspaceEvents = null;
46
+ } catch (_) {
47
+ notifyKeyspaceEvents = null;
48
+ }
49
+
50
+ const recommended = {
51
+ scan_count: 1000,
52
+ max_concurrency: 32,
53
+ max_rps: keyCountEstimate > 100000 ? 2000 : 1000,
54
+ };
55
+
56
+ return {
57
+ keyCountEstimate,
58
+ typeDistribution,
59
+ notifyKeyspaceEvents,
60
+ recommended,
61
+ };
62
+ }