resplite 1.2.6 → 1.2.8

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.
Files changed (33) hide show
  1. package/README.md +168 -275
  2. package/package.json +1 -6
  3. package/scripts/create-interface-smoke.js +32 -0
  4. package/skills/README.md +22 -0
  5. package/skills/resplite-command-vertical-slice/SKILL.md +134 -0
  6. package/skills/resplite-ft-search-workbench/SKILL.md +138 -0
  7. package/skills/resplite-migration-cutover-assistant/SKILL.md +138 -0
  8. package/spec/00-INDEX.md +37 -0
  9. package/spec/01-overview-and-goals.md +125 -0
  10. package/spec/02-protocol-and-commands.md +174 -0
  11. package/spec/03-data-model-ttl-transactions.md +157 -0
  12. package/spec/04-cache-architecture.md +171 -0
  13. package/spec/05-scan-admin-implementation.md +379 -0
  14. package/spec/06-migration-strategy-core.md +79 -0
  15. package/spec/07-type-lists.md +202 -0
  16. package/spec/08-type-sorted-sets.md +220 -0
  17. package/spec/{SPEC_D.md → 09-search-ft-commands.md} +3 -1
  18. package/spec/{SPEC_E.md → 10-blocking-commands.md} +3 -1
  19. package/spec/{SPEC_F.md → 11-migration-dirty-registry.md} +61 -147
  20. package/src/commands/object.js +17 -0
  21. package/src/commands/registry.js +2 -0
  22. package/src/engine/engine.js +11 -0
  23. package/src/migration/apply-dirty.js +8 -1
  24. package/src/migration/index.js +5 -4
  25. package/src/migration/migrate-search.js +25 -6
  26. package/test/integration/object-idletime.test.js +51 -0
  27. package/test/unit/migrate-search.test.js +50 -2
  28. package/spec/SPEC_A.md +0 -1171
  29. package/spec/SPEC_B.md +0 -426
  30. package/src/cli/import-from-redis.js +0 -194
  31. package/src/cli/resplite-dirty-tracker.js +0 -92
  32. package/src/cli/resplite-import.js +0 -296
  33. package/test/contract/import-from-redis.test.js +0 -83
package/spec/SPEC_B.md DELETED
@@ -1,426 +0,0 @@
1
-
2
- ## Appendix B: Lists (Type = list)
3
-
4
- ### B.1 Goals
5
-
6
- * Provide Redis-compatible LIST commands over RESP2, backed by SQLite persistence.
7
- * Avoid O(n) reindexing for push/pop operations.
8
- * Support binary-safe values (`Buffer`) and keys as `BLOB`.
9
- * Maintain correct Redis semantics for empty lists and wrong-type errors.
10
-
11
- ### B.2 Supported Commands (vNext)
12
-
13
- * `LPUSH key value [value ...]`
14
- * `RPUSH key value [value ...]`
15
- * `LPOP key [count]`
16
- * `RPOP key [count]`
17
- * `LLEN key`
18
- * `LRANGE key start stop`
19
- * `LINDEX key index` (optional, recommended)
20
- * `LSET key index value` (optional, later)
21
- * `LTRIM key start stop` (optional, later)
22
-
23
- **Not supported initially**
24
-
25
- * Blocking commands: `BLPOP`, `BRPOP`, `BRPOPLPUSH`
26
- * `RPOPLPUSH`, `LMOVE` (later if needed)
27
-
28
- ### B.3 Redis Semantics
29
-
30
- * **Wrong type:** If `key` exists and is not a list, return:
31
-
32
- * `WRONGTYPE Operation against a key holding the wrong kind of value`
33
- * **Non-existent key:**
34
-
35
- * `LLEN` returns `0`
36
- * `LRANGE` returns empty array
37
- * `LPOP/RPOP` returns `nil` (or empty array for count > 1)
38
- * **Empty list after removals:** When a list becomes empty, delete the logical key:
39
-
40
- * delete metadata from `redis_keys`
41
- * delete list meta/items rows
42
-
43
- ### B.4 Data Model (SQLite)
44
-
45
- Lists are stored using a monotonic sequence strategy per key to avoid shifting indices.
46
-
47
- #### B.4.1 Metadata table
48
-
49
- ```sql
50
- CREATE TABLE redis_list_meta (
51
- key BLOB PRIMARY KEY,
52
- head_seq INTEGER NOT NULL,
53
- tail_seq INTEGER NOT NULL,
54
- FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
55
- );
56
- ```
57
-
58
- **Invariant:**
59
-
60
- * Empty list should not exist (no meta row). If it exists, it must satisfy `head_seq <= tail_seq`.
61
-
62
- #### B.4.2 Items table
63
-
64
- ```sql
65
- CREATE TABLE redis_list_items (
66
- key BLOB NOT NULL,
67
- seq INTEGER NOT NULL,
68
- value BLOB NOT NULL,
69
- PRIMARY KEY (key, seq),
70
- FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
71
- );
72
-
73
- CREATE INDEX redis_list_items_key_seq_idx ON redis_list_items(key, seq);
74
- ```
75
-
76
- ### B.5 Sequence Strategy
77
-
78
- * Each list key maintains:
79
-
80
- * `head_seq`: the smallest sequence currently used (front)
81
- * `tail_seq`: the largest sequence currently used (back)
82
- * For a new list:
83
-
84
- * initialize `head_seq = 0`, `tail_seq = -1` (or similar empty sentinel)
85
- * but we recommend **not creating meta** until first push.
86
-
87
- #### Push operations
88
-
89
- * `LPUSH`:
90
-
91
- * decrement `head_seq` and insert new items at `seq = head_seq - 1` per pushed element
92
- * `RPUSH`:
93
-
94
- * increment `tail_seq` and insert new items at `seq = tail_seq + 1`
95
-
96
- Maintain ordering:
97
-
98
- * front is smaller `seq`, back is larger `seq`.
99
-
100
- ### B.6 Command Behavior
101
-
102
- #### B.6.1 LPUSH
103
-
104
- **Request:** `LPUSH key v1 v2 ...`
105
- **Response:** Integer = new list length
106
-
107
- Implementation notes:
108
-
109
- * If key does not exist: create `redis_keys` type list + create `redis_list_meta`.
110
- * Insert values in left-push order consistent with Redis:
111
-
112
- * `LPUSH mylist a b c` results in list `[c, b, a]` at the head.
113
- * Use a single transaction:
114
-
115
- * ensure type
116
- * upsert meta
117
- * insert items
118
- * bump `redis_keys.version`, update `updated_at`
119
-
120
- #### B.6.2 RPUSH
121
-
122
- Same structure, response is new length.
123
-
124
- #### B.6.3 LLEN
125
-
126
- Return length:
127
-
128
- * If key not exist: `0`
129
- * Else length = `tail_seq - head_seq + 1` (derived from meta)
130
- * Do not count rows for performance.
131
-
132
- #### B.6.4 LPOP / RPOP
133
-
134
- * Without `count`: return bulk string or `nil`
135
- * With `count`:
136
-
137
- * Redis returns array of popped elements
138
- * If list has fewer than count: return all available
139
- * If key does not exist: return `nil` for no-count, or empty array for count
140
- * After popping last element: delete key and meta.
141
-
142
- **Implementation:**
143
-
144
- * In a transaction:
145
-
146
- * read meta
147
- * compute seq range to remove
148
- * select values for response
149
- * delete those rows
150
- * update meta head/tail accordingly
151
- * if empty -> delete key meta entirely
152
-
153
- #### B.6.5 LRANGE
154
-
155
- `LRANGE key start stop` returns array of elements.
156
-
157
- Index rules (Redis):
158
-
159
- * `start` and `stop` are inclusive.
160
- * Negative indices count from end (`-1` is last element).
161
- * Clamp indices to valid bounds.
162
- * If start > stop after normalization: empty array.
163
-
164
- **Mapping indices to sequences:**
165
-
166
- * Let `len = tail_seq - head_seq + 1`
167
- * Normalize start/stop into `[0..len-1]`
168
- * Convert to seq:
169
-
170
- * `seq_start = head_seq + start`
171
- * `seq_stop = head_seq + stop`
172
- * SQL:
173
-
174
- * `SELECT value FROM redis_list_items WHERE key=? AND seq BETWEEN ? AND ? ORDER BY seq ASC`
175
-
176
- ### B.7 Expiration and Cache
177
-
178
- * TTL is stored in `redis_keys.expires_at`, same as other types.
179
- * Lazy expiration must delete list meta/items like other types.
180
- * Cache strategy (recommended):
181
-
182
- * Cache small lists (len <= configured threshold) optionally.
183
- * Otherwise cache only meta (`head_seq`, `tail_seq`) if beneficial.
184
- * Always invalidate on list writes.
185
-
186
- ### B.8 Complexity Targets
187
-
188
- * `LPUSH/RPUSH`: O(k) inserts, no reindexing
189
- * `LPOP/RPOP`: O(k) deletes + selects for return
190
- * `LLEN`: O(1)
191
- * `LRANGE`: O(n) in returned range
192
-
193
- ### B.9 Tests (Required)
194
-
195
- For each command above:
196
-
197
- * Happy path
198
- * Non-existent key behavior
199
- * Wrong-type behavior
200
- * TTL interaction (expires then acts as missing)
201
- * Persistence across restart
202
- * Binary safety for values
203
- * Concurrency sanity (multiple clients pushing/popping does not corrupt meta)
204
-
205
- ---
206
-
207
- ## Appendix C: Sorted Sets / ZSET (Type = zset)
208
-
209
- ### C.1 Goals
210
-
211
- * Provide a Redis-compatible subset of ZSET commands with efficient range and score queries.
212
- * Persist in SQLite with appropriate indexing.
213
- * Keep semantics close to Redis for ordering, score ties, and missing elements.
214
-
215
- ### C.2 Supported Commands (vNext)
216
-
217
- Recommended minimal set:
218
-
219
- * `ZADD key [NX|XX] [CH] [INCR] score member [score member ...]` (start with a reduced subset)
220
- * `ZREM key member [member ...]`
221
- * `ZCARD key`
222
- * `ZSCORE key member`
223
- * `ZRANGE key start stop [WITHSCORES]`
224
- * `ZREVRANGE key start stop [WITHSCORES]` (optional but useful)
225
- * `ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]`
226
- * `ZREMRANGEBYSCORE key min max` (optional, later)
227
- * `ZSCAN key cursor [MATCH pattern] [COUNT n]` (later)
228
-
229
- **Initial simplification for v1 of ZSET:**
230
-
231
- * Support `ZADD key score member [score member ...]` (no flags) returning number of new elements.
232
- * Add flags later.
233
-
234
- ### C.3 Redis Semantics
235
-
236
- * Sorted set is ordered by:
237
-
238
- 1. score ascending
239
- 2. member lexicographically ascending as tie-breaker (Redis behavior)
240
- * Wrong type errors same pattern as other types.
241
- * Non-existent key:
242
-
243
- * `ZCARD` => `0`
244
- * `ZRANGE` => empty array
245
- * `ZSCORE` => `nil`
246
-
247
- ### C.4 Data Model (SQLite)
248
-
249
- ```sql
250
- CREATE TABLE redis_zsets (
251
- key BLOB NOT NULL,
252
- member BLOB NOT NULL,
253
- score REAL NOT NULL,
254
- PRIMARY KEY (key, member),
255
- FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
256
- );
257
-
258
- CREATE INDEX redis_zsets_key_score_member_idx
259
- ON redis_zsets(key, score, member);
260
- ```
261
-
262
- Notes:
263
-
264
- * `PRIMARY KEY (key, member)` allows upsert of member score.
265
- * Secondary index supports score range scans and stable ordering by `(score, member)`.
266
-
267
- ### C.5 Command Behavior
268
-
269
- #### C.5.1 ZADD
270
-
271
- **Minimal v1 behavior:**
272
-
273
- * `ZADD key score member [score member ...]`
274
- * Response: integer count of **new** members added (not updated).
275
- * If key does not exist: create metadata type zset.
276
- * For existing member: update score (does not increment return count).
277
- * Use one transaction:
278
-
279
- * ensure type
280
- * upsert all pairs
281
- * bump key version
282
-
283
- **Later flags (optional):**
284
-
285
- * `NX`: only add new
286
- * `XX`: only update existing
287
- * `CH`: count changed elements
288
- * `INCR`: single member increment
289
-
290
- #### C.5.2 ZREM
291
-
292
- * Remove one or more members.
293
- * Response: integer number removed.
294
- * If zset becomes empty: delete key metadata.
295
-
296
- #### C.5.3 ZCARD
297
-
298
- * Return cardinality:
299
-
300
- * Prefer `SELECT COUNT(*)` (acceptable).
301
- * If performance needs: maintain count in a meta table (not needed initially).
302
-
303
- #### C.5.4 ZSCORE
304
-
305
- * Return bulk string representing score (Redis returns string form), or `nil`.
306
- * Store as `REAL`, but serialize consistently:
307
-
308
- * Use a stable conversion (avoid scientific notation surprises if possible).
309
- * Accept that exact formatting may differ from Redis; document if needed.
310
-
311
- #### C.5.5 ZRANGE (by rank)
312
-
313
- **Request:** `ZRANGE key start stop [WITHSCORES]`
314
-
315
- Rank rules like LRANGE:
316
-
317
- * start/stop inclusive
318
- * negative indexes from end
319
- * clamp
320
-
321
- Implementation:
322
-
323
- * let `len = ZCARD`
324
- * normalize range
325
- * SQL for ordering:
326
-
327
- ```sql
328
- SELECT member, score
329
- FROM redis_zsets
330
- WHERE key=?
331
- ORDER BY score ASC, member ASC
332
- LIMIT ? OFFSET ?;
333
- ```
334
- * Response:
335
-
336
- * without WITHSCORES: array of members
337
- * with WITHSCORES: array `[member1, score1, member2, score2, ...]`
338
-
339
- #### C.5.6 ZRANGEBYSCORE
340
-
341
- **Request:** `ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]`
342
-
343
- Score bounds rules:
344
-
345
- * Support numeric `min/max`.
346
- * Optional later: `(` exclusive bounds, `-inf`, `+inf`.
347
-
348
- Implementation:
349
-
350
- ```sql
351
- SELECT member, score
352
- FROM redis_zsets
353
- WHERE key=? AND score >= ? AND score <= ?
354
- ORDER BY score ASC, member ASC
355
- LIMIT ? OFFSET ?;
356
- ```
357
-
358
- Return format same as `ZRANGE`.
359
-
360
- ### C.6 Expiration and Cache
361
-
362
- * TTL is in `redis_keys.expires_at`.
363
- * Lazy expiration removes zset rows via cascade.
364
- * Cache:
365
-
366
- * Cache `ZSCORE` lookups optionally (key+member) if beneficial.
367
- * Avoid caching full zsets early.
368
- * Always invalidate on `ZADD/ZREM`.
369
-
370
- ### C.7 Complexity Targets
371
-
372
- * `ZADD`: O(k log n) effectively via index maintenance, practical for SQLite
373
- * `ZRANGE`: O(m) over returned slice with index support
374
- * `ZRANGEBYSCORE`: O(m) over match range with index support
375
- * `ZSCORE`: O(log n) via PK on (key, member)
376
-
377
- ### C.8 Tests (Required)
378
-
379
- * Correct ordering:
380
-
381
- * by score, then by member for ties
382
- * Rank normalization:
383
-
384
- * negative indices
385
- * out of range
386
- * start > stop -> empty
387
- * Score range:
388
-
389
- * boundaries inclusive
390
- * LIMIT behavior
391
- * Wrong type behavior
392
- * TTL interaction and lazy deletion
393
- * Persistence across restart
394
- * Binary member support
395
- * Concurrency sanity:
396
-
397
- * multiple clients doing `ZADD` on same key does not corrupt ordering or counts
398
-
399
- ---
400
-
401
- ## Small integration notes (for both LIST and ZSET)
402
-
403
- ### Type constants
404
-
405
- Extend `redis_keys.type` enum:
406
-
407
- * `4 = list`
408
- * `5 = zset`
409
-
410
- ### Wrong-type enforcement
411
-
412
- Any command on a key must:
413
-
414
- 1. run lazy-expire check
415
- 2. read `redis_keys.type`
416
- 3. if mismatch, return WRONGTYPE
417
-
418
- ### Key deletion when empty
419
-
420
- For list and zset:
421
-
422
- * if becomes empty, delete metadata key row (and meta/items rows if any)
423
-
424
- ### SCAN behavior
425
-
426
- * SCAN should include list/zset keys automatically (it reads from `redis_keys`).
@@ -1,194 +0,0 @@
1
- /**
2
- * Import data from Redis into RESPlite SQLite DB (SPEC §26).
3
- * External CLI: connect to Redis, SCAN keys, TYPE, fetch by type, PTTL, write to SQLite.
4
- * Usage: node src/cli/import-from-redis.js --redis-url redis://127.0.0.1:6379 --db ./data.db
5
- */
6
-
7
- import { createClient } from 'redis';
8
- import { fileURLToPath } from 'node:url';
9
- import { openDb } from '../storage/sqlite/db.js';
10
- import { createKeysStorage } from '../storage/sqlite/keys.js';
11
- import { createStringsStorage } from '../storage/sqlite/strings.js';
12
- import { createHashesStorage } from '../storage/sqlite/hashes.js';
13
- import { createSetsStorage } from '../storage/sqlite/sets.js';
14
- import { createListsStorage } from '../storage/sqlite/lists.js';
15
- import { createZsetsStorage } from '../storage/sqlite/zsets.js';
16
- import { asKey, asValue } from '../util/buffers.js';
17
-
18
- const SUPPORTED_TYPES = new Set(['string', 'hash', 'set', 'list', 'zset']);
19
-
20
- function parseArgs() {
21
- const args = process.argv.slice(2);
22
- let redisUrl = null;
23
- let host = '127.0.0.1';
24
- let port = 6379;
25
- let dbPath = null;
26
- let pragmaTemplate = 'default';
27
-
28
- for (let i = 0; i < args.length; i++) {
29
- if (args[i] === '--redis-url' && args[i + 1]) {
30
- redisUrl = args[++i];
31
- } else if (args[i] === '--host' && args[i + 1]) {
32
- host = args[++i];
33
- } else if (args[i] === '--port' && args[i + 1]) {
34
- port = parseInt(args[++i], 10);
35
- } else if (args[i] === '--db' && args[i + 1]) {
36
- dbPath = args[++i];
37
- } else if (args[i] === '--pragma-template' && args[i + 1]) {
38
- pragmaTemplate = args[++i];
39
- }
40
- }
41
-
42
- if (!dbPath) {
43
- console.error('Usage: node import-from-redis.js --db <path> [--redis-url <url> | --host <host> --port <port>] [--pragma-template <name>]');
44
- process.exit(1);
45
- }
46
-
47
- return {
48
- redisUrl: redisUrl || `redis://${host}:${port}`,
49
- dbPath,
50
- pragmaTemplate,
51
- };
52
- }
53
-
54
- function toBuffer(value) {
55
- if (value == null) return null;
56
- if (Buffer.isBuffer(value)) return value;
57
- if (typeof value === 'string') return Buffer.from(value, 'utf8');
58
- return Buffer.from(String(value), 'utf8');
59
- }
60
-
61
- /**
62
- * Normalize scan result: node-redis can return { cursor, keys } or [cursor, keys].
63
- */
64
- function parseScanResult(result) {
65
- if (Array.isArray(result)) {
66
- return { cursor: parseInt(result[0], 10), keys: result[1] || [] };
67
- }
68
- if (result && typeof result === 'object') {
69
- const cursor = typeof result.cursor === 'number' ? result.cursor : parseInt(String(result.cursor), 10);
70
- const keys = result.keys || [];
71
- return { cursor, keys };
72
- }
73
- return { cursor: 0, keys: [] };
74
- }
75
-
76
- async function importFromRedis(redisClient, dbPath, options = {}) {
77
- const { pragmaTemplate = 'default' } = options;
78
- const db = openDb(dbPath, { pragmaTemplate });
79
- const keys = createKeysStorage(db);
80
- const strings = createStringsStorage(db, keys);
81
- const hashes = createHashesStorage(db, keys);
82
- const sets = createSetsStorage(db, keys);
83
- const lists = createListsStorage(db, keys);
84
- const zsets = createZsetsStorage(db, keys);
85
-
86
- const now = Date.now();
87
- const stats = { string: 0, hash: 0, set: 0, list: 0, zset: 0, skipped: 0, errors: 0 };
88
-
89
- let cursor = 0;
90
- do {
91
- const result = await redisClient.scan(cursor);
92
- const parsed = parseScanResult(result);
93
- cursor = parsed.cursor;
94
- const keyList = parsed.keys || [];
95
-
96
- for (const keyName of keyList) {
97
- try {
98
- const type = (await redisClient.type(keyName)).toLowerCase();
99
- if (!SUPPORTED_TYPES.has(type)) {
100
- stats.skipped++;
101
- continue;
102
- }
103
-
104
- let pttl = await redisClient.pTTL(keyName);
105
- if (pttl === -2) pttl = -1;
106
- const expiresAt = pttl > 0 ? now + pttl : null;
107
- const keyBuf = asKey(keyName);
108
-
109
- if (type === 'string') {
110
- const value = await redisClient.get(keyName);
111
- if (value !== undefined && value !== null) {
112
- strings.set(keyBuf, asValue(value), { expiresAt, updatedAt: now });
113
- stats.string++;
114
- }
115
- } else if (type === 'hash') {
116
- const obj = await redisClient.hGetAll(keyName);
117
- if (obj && typeof obj === 'object') {
118
- const pairs = [];
119
- for (const [f, v] of Object.entries(obj)) {
120
- pairs.push(toBuffer(f), toBuffer(v));
121
- }
122
- if (pairs.length) {
123
- hashes.setMultiple(keyBuf, pairs, { updatedAt: now });
124
- keys.setExpires(keyBuf, expiresAt, now);
125
- stats.hash++;
126
- }
127
- }
128
- } else if (type === 'set') {
129
- const members = await redisClient.sMembers(keyName);
130
- if (members && members.length) {
131
- const memberBuffers = members.map((m) => toBuffer(m));
132
- sets.add(keyBuf, memberBuffers, { updatedAt: now });
133
- keys.setExpires(keyBuf, expiresAt, now);
134
- stats.set++;
135
- }
136
- } else if (type === 'list') {
137
- const elements = await redisClient.lRange(keyName, 0, -1);
138
- if (elements && elements.length) {
139
- const valueBuffers = elements.map((e) => toBuffer(e));
140
- lists.rpush(keyBuf, valueBuffers, { updatedAt: now });
141
- keys.setExpires(keyBuf, expiresAt, now);
142
- stats.list++;
143
- }
144
- } else if (type === 'zset') {
145
- const withScores = await redisClient.zRangeWithScores(keyName, 0, -1);
146
- if (withScores && withScores.length) {
147
- const pairs = withScores.map((item) => ({
148
- member: toBuffer(item.value),
149
- score: Number(item.score),
150
- }));
151
- zsets.add(keyBuf, pairs, { updatedAt: now });
152
- keys.setExpires(keyBuf, expiresAt, now);
153
- stats.zset++;
154
- }
155
- }
156
- } catch (err) {
157
- stats.errors++;
158
- console.error(`Error importing key "${keyName}":`, err.message);
159
- }
160
- }
161
- } while (cursor !== 0);
162
-
163
- return { db, stats };
164
- }
165
-
166
- async function main() {
167
- const { redisUrl, dbPath, pragmaTemplate } = parseArgs();
168
-
169
- const client = createClient({ url: redisUrl });
170
- client.on('error', (err) => console.error('Redis client error:', err.message));
171
-
172
- await client.connect();
173
-
174
- try {
175
- console.log(`Importing from ${redisUrl} into ${dbPath} ...`);
176
- const { stats } = await importFromRedis(client, dbPath, { pragmaTemplate });
177
- console.log('Import complete.');
178
- console.log(` strings: ${stats.string}, hashes: ${stats.hash}, sets: ${stats.set}, lists: ${stats.list}, zsets: ${stats.zset}`);
179
- if (stats.skipped) console.log(` skipped (unsupported type): ${stats.skipped}`);
180
- if (stats.errors) console.log(` errors: ${stats.errors}`);
181
- } finally {
182
- await client.quit();
183
- }
184
- }
185
-
186
- const isMain = process.argv[1] === fileURLToPath(import.meta.url);
187
- if (isMain) {
188
- main().catch((err) => {
189
- console.error(err);
190
- process.exit(1);
191
- });
192
- }
193
-
194
- export { importFromRedis, parseScanResult, parseArgs };
@@ -1,92 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * resplite-dirty-tracker (SPEC_F §F.6): subscribe to Redis keyspace notifications, record dirty keys in SQLite.
4
- * Usage: resplite-dirty-tracker start|stop [options]
5
- */
6
-
7
- import { fileURLToPath } from 'node:url';
8
- import { openDb } from '../storage/sqlite/db.js';
9
- import { getRun, setRunStatus, RUN_STATUS } from '../migration/registry.js';
10
- import { startDirtyTracker } from '../migration/tracker.js';
11
-
12
- function parseArgs(argv = process.argv.slice(2)) {
13
- const args = { _: [] };
14
- for (let i = 0; i < argv.length; i++) {
15
- const arg = argv[i];
16
- if (arg === '--from' && argv[i + 1]) args.from = argv[++i];
17
- else if (arg === '--to' && argv[i + 1]) args.to = argv[++i];
18
- else if (arg === '--run-id' && argv[i + 1]) args.runId = argv[++i];
19
- else if (arg === '--channels' && argv[i + 1]) args.channels = argv[++i];
20
- else if (arg === '--pragma-template' && argv[i + 1]) args.pragmaTemplate = argv[++i];
21
- else if (arg === '--config-command' && argv[i + 1]) args.configCommand = argv[++i];
22
- else if (!arg.startsWith('--')) args._.push(arg);
23
- }
24
- return args;
25
- }
26
-
27
- async function startTrackerCli(args) {
28
- const redisUrl = args.from || process.env.RESPLITE_IMPORT_FROM || 'redis://127.0.0.1:6379';
29
- const dbPath = args.to;
30
- const runId = args.runId || process.env.RESPLITE_RUN_ID;
31
- if (!dbPath || !runId) {
32
- console.error('Usage: resplite-dirty-tracker start --run-id <id> --to <db-path> [--from <redis-url>] [--config-command <name>]');
33
- process.exit(1);
34
- }
35
-
36
- const tracker = await startDirtyTracker({
37
- from: redisUrl,
38
- to: dbPath,
39
- runId,
40
- pragmaTemplate: args.pragmaTemplate || 'default',
41
- configCommand: args.configCommand || 'CONFIG',
42
- });
43
-
44
- console.log('Subscribed to __keyevent@0__:* — dirty tracker running. Ctrl+C to stop.');
45
-
46
- const shutdown = async () => {
47
- console.log('Stopping dirty tracker...');
48
- await tracker.stop();
49
- process.exit(0);
50
- };
51
-
52
- process.on('SIGINT', shutdown);
53
- process.on('SIGTERM', shutdown);
54
- }
55
-
56
- async function stopTracker(args) {
57
- const dbPath = args.to;
58
- const runId = args.runId || process.env.RESPLITE_RUN_ID;
59
- if (!dbPath || !runId) {
60
- console.error('Usage: resplite-dirty-tracker stop --run-id <id> --to <db-path>');
61
- process.exit(1);
62
- }
63
- const db = openDb(dbPath, { pragmaTemplate: args.pragmaTemplate || 'default' });
64
- const run = getRun(db, runId);
65
- if (run && run.status === RUN_STATUS.RUNNING) {
66
- setRunStatus(db, runId, RUN_STATUS.PAUSED);
67
- console.log('Run', runId, 'status set to paused. Tracker process must be stopped with Ctrl+C if still running.');
68
- } else {
69
- console.log('Run', runId, 'status:', run?.status ?? 'not found');
70
- }
71
- }
72
-
73
- async function main() {
74
- const args = parseArgs();
75
- const sub = args._[0];
76
- if (sub === 'start') await startTrackerCli(args);
77
- else if (sub === 'stop') await stopTracker(args);
78
- else {
79
- console.error('Usage: resplite-dirty-tracker <start|stop> --run-id <id> --to <db-path> [--from <redis-url>]');
80
- process.exit(1);
81
- }
82
- }
83
-
84
- const isMain = process.argv[1] === fileURLToPath(import.meta.url) || process.argv[1]?.endsWith('resplite-dirty-tracker.js');
85
- if (isMain) {
86
- main().catch((e) => {
87
- console.error(e);
88
- process.exit(1);
89
- });
90
- }
91
-
92
- export { parseArgs, stopTracker };