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.
- package/README.md +168 -275
- package/package.json +1 -6
- package/scripts/create-interface-smoke.js +32 -0
- package/skills/README.md +22 -0
- package/skills/resplite-command-vertical-slice/SKILL.md +134 -0
- package/skills/resplite-ft-search-workbench/SKILL.md +138 -0
- package/skills/resplite-migration-cutover-assistant/SKILL.md +138 -0
- package/spec/00-INDEX.md +37 -0
- package/spec/01-overview-and-goals.md +125 -0
- package/spec/02-protocol-and-commands.md +174 -0
- package/spec/03-data-model-ttl-transactions.md +157 -0
- package/spec/04-cache-architecture.md +171 -0
- package/spec/05-scan-admin-implementation.md +379 -0
- package/spec/06-migration-strategy-core.md +79 -0
- package/spec/07-type-lists.md +202 -0
- package/spec/08-type-sorted-sets.md +220 -0
- package/spec/{SPEC_D.md → 09-search-ft-commands.md} +3 -1
- package/spec/{SPEC_E.md → 10-blocking-commands.md} +3 -1
- package/spec/{SPEC_F.md → 11-migration-dirty-registry.md} +61 -147
- package/src/commands/object.js +17 -0
- package/src/commands/registry.js +2 -0
- package/src/engine/engine.js +11 -0
- package/src/migration/apply-dirty.js +8 -1
- package/src/migration/index.js +5 -4
- package/src/migration/migrate-search.js +25 -6
- package/test/integration/object-idletime.test.js +51 -0
- package/test/unit/migrate-search.test.js +50 -2
- package/spec/SPEC_A.md +0 -1171
- package/spec/SPEC_B.md +0 -426
- package/src/cli/import-from-redis.js +0 -194
- package/src/cli/resplite-dirty-tracker.js +0 -92
- package/src/cli/resplite-import.js +0 -296
- 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 };
|