resplite 1.2.4 → 1.2.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 CHANGED
@@ -217,10 +217,19 @@ const ks = await m.enableKeyspaceNotifications();
217
217
  // → { ok: true, previous: '', applied: 'KEA' }
218
218
  // If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
219
219
 
220
+ // Step 0c — Start dirty tracking (in-process, same script)
221
+ await m.startDirtyTracker({
222
+ onProgress: (p) => {
223
+ // one callback per keyspace event tracked during bulk/cutover
224
+ console.log(`[dirty ${p.totalEvents}] event=${p.event} key=${p.key}`);
225
+ },
226
+ });
227
+
220
228
  // Step 1 — Bulk import (checkpointed, resumable). Same script to start or continue.
221
229
  // Use keyCountEstimate from preflight to show progress % (estimate; actual count may change).
222
230
  const total = info.keyCountEstimate || 1;
223
231
  await m.bulk({
232
+ resume: true,
224
233
  onProgress: (r) => {
225
234
  const pct = total ? ((r.scanned_keys / total) * 100).toFixed(1) : '—';
226
235
  console.log(
@@ -236,6 +245,9 @@ console.log('bulk status:', run.status, '— dirty counts:', dirty);
236
245
  // Step 2 — Apply dirty keys that changed in Redis during bulk
237
246
  await m.applyDirty();
238
247
 
248
+ // Step 2b — Stop tracker after cutover
249
+ await m.stopDirtyTracker();
250
+
239
251
  // Step 3 — Verify a sample of keys match between Redis and the destination
240
252
  const result = await m.verify({ samplePct: 0.5, maxSample: 10000 });
241
253
  console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches.length}`);
@@ -244,13 +256,13 @@ console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches
244
256
  await m.close();
245
257
  ```
246
258
 
247
- **Automatic resume (default)**
259
+ **Bult: Automatic resume (default)**
248
260
  `resume` defaults to `true`. It doesn't matter whether it's the first run or a resume: the same script works for both starting and continuing. The first run starts from cursor 0; if the process is interrupted (Ctrl+C, crash, etc.), running the script again continues from the last checkpoint. You don't need to pass `resume: false` on the first run or change anything to resume.
249
261
 
250
262
  **Graceful shutdown**
251
263
  On SIGINT (Ctrl+C) or SIGTERM, the bulk importer checkpoints progress, sets the run status to `aborted`, closes the SQLite database cleanly (so WAL is checkpointed and the file is not left open), then exits. You can safely interrupt a long-running bulk and resume later.
252
264
 
253
- The dirty-key tracker (to capture writes during bulk) still runs as a separate process via `npx resplite-dirty-tracker`. The API above handles everything else in a single script.
265
+ The JS API can run the dirty-key tracker in-process via `m.startDirtyTracker()` / `m.stopDirtyTracker()`, so the full flow can run from a single script. You can still use `npx resplite-dirty-tracker start|stop` if you prefer a separate process.
254
266
 
255
267
  #### Renamed CONFIG command
256
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -26,6 +26,7 @@ import { runApplyDirty } from './apply-dirty.js';
26
26
  import { runVerify } from './verify.js';
27
27
  import { runMigrateSearch } from './migrate-search.js';
28
28
  import { getRun, getDirtyCounts } from './registry.js';
29
+ import { startDirtyTracker as startDirtyTrackerProcess } from './tracker.js';
29
30
 
30
31
  /**
31
32
  * @typedef {object} MigrationOptions
@@ -47,6 +48,8 @@ import { getRun, getDirtyCounts } from './registry.js';
47
48
  * @returns {{
48
49
  * preflight(): Promise<object>,
49
50
  * enableKeyspaceNotifications(opts?: { value?: string, merge?: boolean }): Promise<{ ok: boolean, previous: string|null, applied: string, error?: string }>,
51
+ * startDirtyTracker(opts?: { pragmaTemplate?: string, onProgress?: function }): Promise<{ running: true }>,
52
+ * stopDirtyTracker(): Promise<{ running: false }>,
50
53
  * bulk(opts?: { resume?: boolean, onProgress?: function }): Promise<object>,
51
54
  * status(): { run: object, dirty: object } | null,
52
55
  * applyDirty(opts?: { batchKeys?: number, maxRps?: number }): Promise<object>,
@@ -69,6 +72,7 @@ export function createMigration({
69
72
  if (!to) throw new Error('createMigration: "to" (db path) is required');
70
73
 
71
74
  let _client = null;
75
+ let _tracker = null;
72
76
 
73
77
  async function getClient() {
74
78
  if (_client) return _client;
@@ -113,6 +117,41 @@ export function createMigration({
113
117
  return setKeyspaceEvents(client, value, { configCommand, merge });
114
118
  },
115
119
 
120
+ /**
121
+ * Start dirty-key tracking in-process for this migration controller.
122
+ * Use this to run the full minimal-downtime flow in one Node script.
123
+ *
124
+ * @param {{
125
+ * pragmaTemplate?: string,
126
+ * onProgress?: (progress: { runId: string, key: string, event: string, totalEvents: number, at: string }) => void | Promise<void>
127
+ * }} [opts]
128
+ */
129
+ async startDirtyTracker({ pragmaTemplate: pt = pragmaTemplate, onProgress } = {}) {
130
+ if (_tracker) return { running: true };
131
+ const id = requireRunId();
132
+ _tracker = await startDirtyTrackerProcess({
133
+ from,
134
+ to,
135
+ runId: id,
136
+ pragmaTemplate: pt,
137
+ configCommand,
138
+ onProgress,
139
+ });
140
+ return { running: true };
141
+ },
142
+
143
+ /**
144
+ * Stop in-process dirty-key tracking started by `startDirtyTracker`.
145
+ * Safe to call even if tracking is not running.
146
+ */
147
+ async stopDirtyTracker() {
148
+ if (_tracker) {
149
+ await _tracker.stop();
150
+ _tracker = null;
151
+ }
152
+ return { running: false };
153
+ },
154
+
116
155
  /**
117
156
  * Step 1 — Bulk import: SCAN all keys from Redis into the destination DB.
118
157
  * Resume is on by default: first run starts from 0, later runs continue from checkpoint.
@@ -208,6 +247,10 @@ export function createMigration({
208
247
  * Disconnect from Redis. Call when done with all migration operations.
209
248
  */
210
249
  async close() {
250
+ if (_tracker) {
251
+ await _tracker.stop().catch(() => {});
252
+ _tracker = null;
253
+ }
211
254
  if (_client) {
212
255
  await _client.quit().catch(() => {});
213
256
  _client = null;
@@ -27,6 +27,13 @@ const KEYEVENT_PATTERN = '__keyevent@0__:*';
27
27
  * @param {string} options.runId - Migration run identifier.
28
28
  * @param {string} [options.pragmaTemplate='default']
29
29
  * @param {string} [options.configCommand='CONFIG'] - CONFIG command name (in case it was renamed).
30
+ * @param {(progress: {
31
+ * runId: string,
32
+ * key: string,
33
+ * event: string,
34
+ * totalEvents: number,
35
+ * at: string
36
+ * }) => void | Promise<void>} [options.onProgress] - Called for each tracked keyspace event.
30
37
  * @returns {Promise<{ stop(): Promise<void> }>}
31
38
  * @throws {Error} If keyspace notifications are not enabled on Redis.
32
39
  */
@@ -36,6 +43,7 @@ export async function startDirtyTracker({
36
43
  runId,
37
44
  pragmaTemplate = 'default',
38
45
  configCommand = 'CONFIG',
46
+ onProgress,
39
47
  } = {}) {
40
48
  if (!to) throw new Error('startDirtyTracker: "to" (db path) is required');
41
49
  if (!runId) throw new Error('startDirtyTracker: "runId" is required');
@@ -66,12 +74,27 @@ export async function startDirtyTracker({
66
74
  );
67
75
  await subClient.connect();
68
76
 
77
+ let totalEvents = 0;
69
78
  await subClient.pSubscribe(KEYEVENT_PATTERN, (message, channel) => {
70
79
  const event = typeof channel === 'string'
71
80
  ? channel.split(':').pop()
72
81
  : String(channel ?? '').split(':').pop() || 'unknown';
73
82
  try {
74
83
  upsertDirtyKey(db, runId, message, event);
84
+ totalEvents += 1;
85
+ if (onProgress) {
86
+ Promise.resolve(
87
+ onProgress({
88
+ runId,
89
+ key: message,
90
+ event,
91
+ totalEvents,
92
+ at: new Date().toISOString(),
93
+ })
94
+ ).catch((err) => {
95
+ logError(db, runId, 'dirty_apply', 'Tracker onProgress error: ' + err.message, message);
96
+ });
97
+ }
75
98
  } catch (err) {
76
99
  logError(db, runId, 'dirty_apply', err.message, message);
77
100
  }
@@ -100,13 +100,17 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
100
100
  const runId = `tracker-test-${Date.now()}`;
101
101
 
102
102
  const m = createMigration({ from: REDIS_URL, to: dbPath, runId });
103
+ const progressEvents = [];
103
104
 
104
105
  const ks = await m.enableKeyspaceNotifications({ value: 'KEA' });
105
106
  assert.ok(ks.ok, `Failed to enable keyspace notifications: ${ks.error}`);
106
107
 
107
- // ── Start tracker BEFORE bulk ────────────────────────────────────────
108
- const tracker = await startDirtyTracker({ from: REDIS_URL, to: dbPath, runId });
109
-
108
+ // ── Start tracker BEFORE bulk (same-script API) ─────────────────────
109
+ await m.startDirtyTracker({
110
+ onProgress: (p) => {
111
+ progressEvents.push(p);
112
+ },
113
+ });
110
114
  try {
111
115
  // ── Bulk import (captures the initial snapshot) ──────────────────
112
116
  const run = await m.bulk();
@@ -121,7 +125,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
121
125
  // Give the tracker time to process the keyspace events
122
126
  await new Promise((r) => setTimeout(r, 600));
123
127
  } finally {
124
- await tracker.stop();
128
+ await m.stopDirtyTracker();
125
129
  }
126
130
 
127
131
  // ── Verify dirty keys were captured ─────────────────────────────────
@@ -130,6 +134,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
130
134
  dirty.dirty + dirty.deleted >= 3,
131
135
  `Expected ≥3 dirty/deleted keys, got dirty=${dirty.dirty} deleted=${dirty.deleted}`
132
136
  );
137
+ assert.ok(progressEvents.length >= 3, `Expected tracker onProgress events, got ${progressEvents.length}`);
133
138
 
134
139
  // ── Apply-dirty: reconcile post-bulk changes ─────────────────────────
135
140
  const afterApply = await m.applyDirty();