quilltap 4.10.0-dev.99 → 4.11.0-dev.106
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 +70 -11
- package/bin/quilltap.js +20 -3
- package/lib/__tests__/completion-coverage.test.js +1 -0
- package/lib/__tests__/memories-maintain-command.test.js +276 -0
- package/lib/__tests__/migrations-source-ids.test.js +54 -0
- package/lib/__tests__/server-port-resolution.test.js +122 -0
- package/lib/anchor-probe-command.js +222 -0
- package/lib/completion/bash.template +25 -11
- package/lib/completion/fish.template +26 -4
- package/lib/completion/zsh.template +35 -3
- package/lib/db-commands.js +20 -3
- package/lib/graph-integrity.js +108 -5
- package/lib/lock-helpers.js +66 -0
- package/lib/memories-commands.js +474 -75
- package/lib/memories-maintain-command.js +238 -0
- package/lib/memories-server.js +105 -0
- package/lib/memory-diff-command.js +28 -6
- package/lib/migrations-commands.js +18 -4
- package/lib/recall-replay-command.js +195 -11
- package/lib/text-codec.js +30 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ quilltap db logs --tail 20 # Recent LLM logs
|
|
|
135
135
|
|
|
136
136
|
quilltap db message <id> # Full content of one message
|
|
137
137
|
quilltap db log <id> [--field request|response|both]
|
|
138
|
-
quilltap db memories --character Friday [--about Amy] [--source AUTO]
|
|
138
|
+
quilltap db memories --character Friday [--about Amy] [--source AUTO] [--tier hot|cold]
|
|
139
139
|
quilltap db characters status # Per-character vault readiness (--id, --diverged, --blocked)
|
|
140
140
|
```
|
|
141
141
|
|
|
@@ -193,7 +193,7 @@ quilltap db --mount-points --tables # Target the mount index DB
|
|
|
193
193
|
|
|
194
194
|
The database is opened **read-only by default**. Add `--write` to make changes: it opens the database read-write, **claims the instance lock** (`<dataDir>/quilltap.lock`) for the duration, and releases it on exit. It **refuses — with no override — if a running server or another instance holds the lock**, so stop the server first. `--repl` is read-only unless combined with `--write`. Attempting a write without `--write` fails with a hint to re-run with the flag. What "held" means, and how to tell a live lock from a stale one, is [below](#locking).
|
|
195
195
|
|
|
196
|
-
Compressed text columns (`chat_messages.content`, `llm_logs.request` / `response` and friends) are stored as BLOBs.
|
|
196
|
+
Compressed text columns (`chat_messages.content`, `llm_logs.request` / `response` and friends) are stored as BLOBs. A raw query's output decodes them for display, so `SELECT content …` prints text. Inside SQL — `WHERE`, `LIKE`, `substr`, `json_extract` — wrap the column in `qt_text()` to work on the text: `SELECT json_extract(qt_text(response), '$.error') …`.
|
|
197
197
|
|
|
198
198
|
In the REPL, `.cols <table>` and `.find <text>` mirror the subcommand helpers.
|
|
199
199
|
|
|
@@ -234,7 +234,8 @@ quilltap docs list # All mounts
|
|
|
234
234
|
quilltap docs show <mount> # One mount, with counts
|
|
235
235
|
quilltap docs files <mount> [--folder <path>] # Flat file list for a mount
|
|
236
236
|
quilltap docs ls <mount> [path] [--links] # POSIX-flavoured listing (alias: dir)
|
|
237
|
-
quilltap docs
|
|
237
|
+
quilltap docs ls <mount> [path] -R --sort time -r # Recursive, grouped by folder, sorted by time and reversed
|
|
238
|
+
quilltap docs tree <mount> [path] # ASCII tree of a folder hierarchy (--depth, --max-nodes, --long)
|
|
238
239
|
quilltap docs read [--rendered] <mount> <path> # File contents → stdout
|
|
239
240
|
quilltap docs export <mount> <outputDir> # Mount → directory
|
|
240
241
|
quilltap docs find <pattern> # Substring match on file names (--mount, --ext, --type, --limit)
|
|
@@ -258,6 +259,8 @@ quilltap docs rmdir <mount> <path> # Delete an empty
|
|
|
258
259
|
quilltap docs mvdir <mount> <fromPath> <toPath> # Rename/move a folder (server-required)
|
|
259
260
|
```
|
|
260
261
|
|
|
262
|
+
`ls` flags: `--recursive` / `-R` lists every file below the path, grouped by folder; `--sort name|time|size|links` (default `name`) with `--reverse` / `-r` to flip it; `--links` lists the other mount/path entries under each file that has more than one hard link. `tree` takes `--depth N` (default 20), `--max-nodes N` (default 1000) and `--long`, which is accepted but reserved — it does not yet add columns.
|
|
263
|
+
|
|
261
264
|
Mount arguments accept the mount name (case-insensitive) or a UUID; ambiguous names print candidates and exit non-zero. `--json` is supported by every verb; `reindex`, `embed`, `link`, `rmdir`, and `mvdir` refuse to run without a reachable server. `grep --semantic` goes through `POST /api/v1/mount-points?action=semantic-search`, because the embedding provider lives in the server; it defaults to `--top 20`, `--threshold 0.5`, `--port 3000`.
|
|
262
265
|
|
|
263
266
|
### Addressing documents with `qtap://` URIs
|
|
@@ -330,21 +333,33 @@ A character vault's **keystone files** — `properties.json`, the five required
|
|
|
330
333
|
|
|
331
334
|
## Memories
|
|
332
335
|
|
|
333
|
-
`quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable
|
|
336
|
+
`quilltap memories` exposes the same Commonplace Book that each character carries — searchable, sortable, graphable. All verbs open the main encrypted DB read-only; the three that change anything (`maintain`, `validate --repair --write`, `keystone --write`) ask the running server to do the work.
|
|
334
337
|
|
|
335
338
|
```bash
|
|
336
339
|
quilltap memories ls # All holders, default sort: reinforcedImportance DESC
|
|
337
340
|
quilltap memories ls --character Ariadne --sort created --limit 10 # One holder, newest first
|
|
338
341
|
quilltap memories find "concrete examples" # Substring match on summary (--in content|both)
|
|
339
342
|
quilltap memories grep -i --max 3 --context 1 "concrete examples" # Pattern search inside content, with snippets
|
|
343
|
+
quilltap memories grep -l "concrete examples" # Matching memory IDs only, one per line (--paths-only)
|
|
340
344
|
quilltap memories show <id|prefix> [--depth N] [--no-related] # Full record + related-memory neighbourhood
|
|
341
345
|
quilltap memories tree <id|prefix> [--depth N] [--max-nodes N] # ASCII walk of the bidirectional related-memory graph
|
|
342
|
-
quilltap memories status [--character <name|id>]
|
|
343
|
-
quilltap memories validate [--character <name|id>] [--list] #
|
|
346
|
+
quilltap memories status [--character <name|id>] [--port <n>] # Per-holder rollup, maintenance ledger + dangling-edge check
|
|
347
|
+
quilltap memories validate [--character <name|id>] [--list] # Graph health check; exit 1 if any dangling link remains
|
|
348
|
+
quilltap memories validate --repair [--write] # Rehearse (or, with --write, apply) the server's graph repair
|
|
349
|
+
quilltap memories keystone <id|prefix> [--off] [--write] # Rehearse (or, with --write, apply) marking a keystone — a standing fact
|
|
344
350
|
quilltap memories grep --semantic --character Ariadne "the argument" # Embedding search (server required, one holder)
|
|
351
|
+
quilltap memories maintain --character Ariadne --dry-run # Rehearse every maintenance phase (server required)
|
|
352
|
+
quilltap memories maintain --character Ariadne --phases consolidate # Queue a consolidation-only maintenance job
|
|
353
|
+
quilltap memories maintain --character all [--phases demote,retire] # Queue maintenance for every character
|
|
345
354
|
```
|
|
346
355
|
|
|
347
|
-
Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source
|
|
356
|
+
Shared filter flags apply to `ls`, `find`, `grep`, and `status` where they make sense: `--character`, `--about` (with `self` / `none` shortcuts), `--source` (AUTO / MANUAL / CONSOLIDATED), `--tier` (hot / cold; both by default, cold rows are starred in the `src` column), `--keystone` (keystones only; a keystone's summary is prefixed `◆` in tables, and `show` prints a `Keystone:` line), `--chat` (with `none` for manual entries), `--project`, `--since`, `--until`, `--min-importance`, `--min-reinforced`, `--has-embedding` / `--no-embedding`. Sort flags (`--sort reinforced|importance|created|accessed|reinforcement-count|links`, plus `-r` / `--reverse` to flip it, `--limit N` (default 50) and `--full-titles` to stop chat titles being truncated to 32 characters) apply to `ls`, `find`, and `grep`. `grep -l` / `--paths-only` prints only the IDs of matching memories, one per line. Names accept fuzzy substrings; ambiguous names print candidates and exit 2. `--json` is supported by every verb. `validate` is `status`'s terse twin — read-only; it counts dangling `relatedMemoryIds` edges, dangling `consolidatedFrom` members (digests naming notes that are gone), dangling `supersededById` pointers, and rows with no embedding. It exits 1 if any dangling link remains (a missing embedding only warns), and `--list` prints the offending IDs. `grep --semantic` defaults to `--top 20`, `--threshold 0.5`, and scopes to **one holder at a time**: `--character all` is rejected. The legacy `quilltap db memories --character <name>` verb remains, now with `--tier hot|cold` (rows with no tier count as hot).
|
|
357
|
+
|
|
358
|
+
Verbs that call the server (`grep --semantic`, `status`'s ledger, `maintain`, `validate --repair`, `keystone --write`) take `--port`; without it they use the port recorded in the `--instance` / `--data-dir` instance's lock, else 3000.
|
|
359
|
+
|
|
360
|
+
`maintain` runs the memory maintenance job by hand: `fold-near-duplicates`, `consolidate`, `demote`, `retire`, `mirror`, always in that order. `--character <name|id|all>` is required (`all` is every character you own); `--phases a,b,…` picks a subset (checked by the server, which answers an unknown name with a 400; naming `consolidate` brings `mirror` along, since the Commonplace mirror follows consolidation). The CLI resolves the character locally and asks the running server — the model, the embeddings and the only database writer live there. With `--dry-run` the server rehearses the run and writes nothing: the CLI prints each phase's status (ran / skipped, and why), then the fold groups, the consolidation clusters (member notes, proposed digests, standalones, contradictions, run stats), the demotion arithmetic (cap, hot total, demotable, protected, excess, demoted), the retire count and the mirror subjects. A dry run over `all` gives one table row per character and may not include `consolidate` (each would call the model). Without `--dry-run` the server queues one `MEMORY_MAINTENANCE` job per character and the CLI prints the job ids; manual runs ignore the master `enabled` switches. `--max N` caps clusters consolidated (`maxClustersPerRun`, 1–1000; a dry run defaults to 10, at most 40), `--threshold 0..1` overrides the cluster similarity (neural embedding profiles), `--cap N` overrides the hot-row cap for demotion (100–100000). `--json` prints the raw API payload. There is no `consolidate` verb any more: use `maintain --phases consolidate`.
|
|
361
|
+
|
|
362
|
+
`validate --repair` asks the running server to repair what `validate` finds. Without `--write` it is a dry run that prints what would be repaired and changes nothing; with `--write` the server scrubs dangling `relatedMemoryIds`, `consolidatedFrom` and `supersededById` links and queues embedding jobs for rows with none (up to 2000 per run; it needs a default embedding profile). As with `db characters archive`, the write runs inside the server, which holds the instance lock, so the server must be up. `status` also shows the character's maintenance ledger — when each phase last ran and whether the last demotion was effective — when it can reach the running server, and the last three maintenance job rows either way.
|
|
348
363
|
|
|
349
364
|
## Memory Extraction Dry-Run
|
|
350
365
|
|
|
@@ -355,7 +370,9 @@ quilltap memory-diff <chatId> --instance Friday # Report to the current
|
|
|
355
370
|
quilltap memory-diff <chatId> --out /tmp/diff --concurrency 8
|
|
356
371
|
```
|
|
357
372
|
|
|
358
|
-
Needs a running server
|
|
373
|
+
Needs a running server to reach the extraction pipeline: pass `--port`, or name the server's instance with `--instance`/`--data-dir` and the port is read from its lock file (see [Finding the server](#finding-the-server)); otherwise 3000. `--out <dir>` sets the report destination (default: cwd); `--concurrency N` bounds parallel turns (default 4, max 32).
|
|
374
|
+
|
|
375
|
+
The dry run is the live pipeline with nothing written: every turn, then both fold passes (`other-fold`, `episode-fold`) over the history a regenerate would rebuild, autonomous rooms included. Each re-extracted candidate records `extractionPass` and `decision` — what admission would do with it (`insert`, `reinforce`, `absorb`, …). Admission embeds each candidate to decide, so a dry run makes embedding calls.
|
|
359
376
|
|
|
360
377
|
## Recall Replay
|
|
361
378
|
|
|
@@ -365,9 +382,45 @@ Needs a running server (`--port`, default 3000) to reach the extraction pipeline
|
|
|
365
382
|
quilltap recall-replay <chatId> # Replay the last turn
|
|
366
383
|
quilltap recall-replay <chatId> --turn 42 # Replay at interchange 42 (its own clock)
|
|
367
384
|
quilltap recall-replay <chatId> --json # Raw JSON for scripting
|
|
385
|
+
quilltap recall-replay <chatId> --memory-budget 4000 # Budget-sized dynamic head
|
|
386
|
+
quilltap recall-replay <chatId> --turn 42 --json > t42.json
|
|
387
|
+
quilltap recall-replay <chatId> --turn 42 --signals-from t42.json --as-of \
|
|
388
|
+
--tuning '{"boostGateAbs":0.45,"boostGateMargin":0.15,"boostGateRamp":0.1}'
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Flags: `--turn <n>` (default: last), `--char <characterId>` (default: first LLM-controlled participant), `--limit <n>` (default: the larger of 25 and the new path's head size + 10), `--memory-budget <tokens>` (positive integer; the new path's selected rows then follow the budget-sized dynamic head, which scales with the model's memory budget, while the old path keeps the historical fixed 5-entry head; each path header prints its head size), `--instance <name>` / `--data-dir <path>` (find the server's port, see [Finding the server](#finding-the-server)), `--port <n>` (default: from the instance lock, else 3000), `--json`.
|
|
392
|
+
|
|
393
|
+
Tuning and comparable runs:
|
|
394
|
+
|
|
395
|
+
- `--tuning '<json>'` / `--tuning-file <path>` — retuning constants for the new path only (the old path stays the reference): `boostGateAbs`, `boostGateMargin`, `boostGateRamp` (relevance gate on boosts), `boostCap`, `freshBypassesGate`, `windowBypassesGate`, `multipliers` (`{ "freshEvent24h": 1.3, … }`), `specificAnchors`, `anchorMinHits`, `anchorOrder`, `backgroundReserve`, `innerCircleShare` (share of the frozen archive the group inner circle may take, default `0.333…`), `innerCircleAllowance` (digests per circle member by tier, tightest group first, default `[3, 2, 1]`). Unknown keys are rejected. The header's `Tuning` line shows what is active.
|
|
396
|
+
- `--signals-from <replay.json>` — reuse the signals saved in an earlier `--json` replay of the same chat and turn. The distillation is a cheap-LLM call and words the same turn differently each time, so runs you mean to compare should share it.
|
|
397
|
+
- `--as-of` — search only memories created before the turn's opening message, with weights decayed to the turn's own clock: the corpus as it stood then. The header's `Harness` line shows the cutoff and how many later memories were left out.
|
|
398
|
+
|
|
399
|
+
The server memoises the replay's embeddings by text until it restarts, so repeat runs embed identical vectors.
|
|
400
|
+
|
|
401
|
+
After the two tables the replay prints:
|
|
402
|
+
|
|
403
|
+
- **TEXT FALLBACK** — what the new path returns with no usable vector (the text strategy over the same question).
|
|
404
|
+
- **STAGE TRACE** — one line per recall stage: `query` (distilled or pinned, embedding calls), `gather` / `hydrate` / `score` / `select` for the head search (pool size, floor, window mode, expansion, the cut), and `assemble` per pool (what the archive held and its budget cut, how many pool rows the archive removed from the head, what the inter-character section took and skipped as already delivered).
|
|
405
|
+
- **Ring** — the anti-repetition ring the replay ranked under: the one in force at that turn, rebuilt from the `[Recall:ring]` lines each reply's `debugMemoryLogs` carries, or the chat's current ring for a chat older than those records.
|
|
406
|
+
|
|
407
|
+
`--json` adds `fallbackPath`, `trace`, `pools`, `ring` and `ringSource` to the result.
|
|
408
|
+
|
|
409
|
+
## Anchor Probe
|
|
410
|
+
|
|
411
|
+
`quilltap anchor-probe <characterId>` measures whether the episodic anchor line embedded with each memory suppresses reinforcement at admission. It re-embeds the character's most recent memories with and without the anchor line, compares each against older rows, asks admission's own decider (the same subject, kind, source and date-guard rules a new memory meets) what it would do each way, and reports how many rows would fold into an older one and how many would be absorbed as near-duplicates, plus a per-row table (rows that would fold only without anchors are highlighted). Read-only, but it costs embedding calls (roughly up to 7 per sampled row).
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
quilltap anchor-probe <characterId> # Sample the 50 most recent memories
|
|
415
|
+
quilltap anchor-probe <characterId> --limit 20 # Smaller, cheaper sample
|
|
416
|
+
quilltap anchor-probe <characterId> --json # Raw JSON for scripting
|
|
368
417
|
```
|
|
369
418
|
|
|
370
|
-
Flags: `--
|
|
419
|
+
Flags: `--limit <n>` (1–200, default 50), `--instance <name>` / `--data-dir <path>` (find the server's port, see [Finding the server](#finding-the-server)), `--port <n>` (default: from the instance lock, else 3000), `--json`.
|
|
420
|
+
|
|
421
|
+
### Finding the server
|
|
422
|
+
|
|
423
|
+
`memory-diff`, `recall-replay` and `anchor-probe` call a running server over HTTP. The server records the port it listens on in its instance lock (`<dataDir>/data/quilltap.lock`, field `port`; shown by `quilltap db --lock-status`). Resolution order: an explicit `--port` wins; otherwise, if `--instance` or `--data-dir` is given and that lock is live and records a port, that port is used (a dim note says so); otherwise 3000. Giving both `--instance` and `--data-dir` is an error. `--passphrase` is accepted by all three for uniformity; `recall-replay` and `anchor-probe` ignore it. Locks written before this field existed carry no port, so restart the server once to record it.
|
|
371
424
|
|
|
372
425
|
## Maintenance & Cleanup
|
|
373
426
|
|
|
@@ -432,7 +485,13 @@ quilltap themes create sunset # Scaffold a new theme
|
|
|
432
485
|
quilltap themes search "dark" # Search registries
|
|
433
486
|
quilltap themes update # Check for theme updates
|
|
434
487
|
quilltap themes registry list # List configured registries
|
|
435
|
-
quilltap themes registry add <url> # Add a registry source
|
|
488
|
+
quilltap themes registry add <url> # Add a registry source (--key <pubkey>, --name <name>)
|
|
489
|
+
quilltap themes registry remove <name> # Remove a registry source
|
|
490
|
+
quilltap themes registry refresh # Refresh all registry indexes
|
|
491
|
+
|
|
492
|
+
# For registry operators
|
|
493
|
+
quilltap themes registry keygen [--output <dir>] # Generate an Ed25519 keypair
|
|
494
|
+
quilltap themes registry sign <dir> --key <private> # Sign a registry directory
|
|
436
495
|
```
|
|
437
496
|
|
|
438
497
|
## Shell Completion
|
|
@@ -511,7 +570,7 @@ Fish picks new completion files up automatically — no shell restart needed.
|
|
|
511
570
|
|
|
512
571
|
Completions **parse the line rather than counting words**, so a flag typed anywhere the CLI itself accepts one does not derail them: `quilltap docs --instance Friday <TAB>` still offers the `docs` verbs. bash and zsh also reuse the `-i` / `-d` / `--passphrase` already on the line when looking store names up, so the names offered come from the instance you are addressing rather than the default one. fish completes `--mount` but not the positionals, and always reads the default instance.
|
|
513
572
|
|
|
514
|
-
Dynamic completions shell out to `quilltap`'s own subcommands. If the active instance is encrypted and no passphrase is reachable, the completion silently returns nothing rather than prompting in the middle of a tab.
|
|
573
|
+
Dynamic completions shell out to `quilltap`'s own subcommands — `quilltap instances list --names-only` and `quilltap docs list --names-only`, which print one name per line and are handy in scripts too. If the active instance is encrypted and no passphrase is reachable, the completion silently returns nothing rather than prompting in the middle of a tab.
|
|
515
574
|
|
|
516
575
|
## Requirements
|
|
517
576
|
|
package/bin/quilltap.js
CHANGED
|
@@ -14,6 +14,7 @@ const {
|
|
|
14
14
|
openEncryptedDb,
|
|
15
15
|
} = require('../lib/db-helpers');
|
|
16
16
|
const { resolveInstance } = require('../lib/instances');
|
|
17
|
+
const { decodeCompressedTextInRows } = require('../lib/text-codec');
|
|
17
18
|
const { resolveModuleDir, ensureNativeModules, ensureDatabaseNativeModule } = require('../lib/native-modules');
|
|
18
19
|
|
|
19
20
|
const PACKAGE_DIR = path.resolve(__dirname, '..');
|
|
@@ -102,6 +103,7 @@ Subcommands:
|
|
|
102
103
|
file-verify Force-download cloud-evicted data files (iCloud, etc.)
|
|
103
104
|
memory-diff <chatId> Dump existing memories and dry-run re-extraction for a chat
|
|
104
105
|
recall-replay <chatId> Replay a turn's memory recall, old vs episodic ranking
|
|
106
|
+
anchor-probe <characterId> Probe whether episodic anchor lines suppress reinforcement at admission
|
|
105
107
|
completion <shell> Generate a shell completion script (bash / zsh / fish)
|
|
106
108
|
|
|
107
109
|
Options:
|
|
@@ -590,6 +592,9 @@ function handleLockCommand(dataDir, opts) {
|
|
|
590
592
|
console.log(` Environment: ${lock.environment || 'unknown'}`);
|
|
591
593
|
console.log(` Process: ${lock.processTitle || 'unknown'}`);
|
|
592
594
|
console.log(` Started: ${lock.startedAt || 'unknown'}`);
|
|
595
|
+
if (Number.isInteger(lock.port)) {
|
|
596
|
+
console.log(` Port: ${lock.port}`);
|
|
597
|
+
}
|
|
593
598
|
|
|
594
599
|
// Show heartbeat age if available
|
|
595
600
|
if (lock.lastHeartbeat) {
|
|
@@ -764,7 +769,10 @@ Subcommands (high-level shortcuts; auto-pick the right database):
|
|
|
764
769
|
message <id> Full content of a single chat message
|
|
765
770
|
log <id> Full request/response of a single LLM log
|
|
766
771
|
memories --character <id> Memories held by a character
|
|
767
|
-
(flags: --about <id|name>
|
|
772
|
+
(flags: --about <id|name>
|
|
773
|
+
--source AUTO|MANUAL|CONSOLIDATED
|
|
774
|
+
--tier hot|cold (NULL tier counts as hot)
|
|
775
|
+
--limit N)
|
|
768
776
|
characters status Per-character vault readiness report:
|
|
769
777
|
flag value, vault present, single-file count,
|
|
770
778
|
Prompts/ and Scenarios/ folder counts, and any
|
|
@@ -1037,11 +1045,13 @@ async function dbCommand(args) {
|
|
|
1037
1045
|
} else if (countTable) {
|
|
1038
1046
|
const row = db.prepare(`SELECT count(*) as count FROM "${countTable}"`).get();
|
|
1039
1047
|
if (asJson) console.log(JSON.stringify({ table: countTable, count: row.count }, null, 2));
|
|
1040
|
-
else console.log(row.count);
|
|
1048
|
+
else console.log(String(row.count));
|
|
1041
1049
|
} else if (sql) {
|
|
1042
1050
|
const stmt = db.prepare(sql);
|
|
1043
1051
|
if (stmt.reader) {
|
|
1044
1052
|
const rows = stmt.all();
|
|
1053
|
+
// Compressed text columns arrive as Buffers; print them as text (bug 173).
|
|
1054
|
+
decodeCompressedTextInRows(rows);
|
|
1045
1055
|
if (asJson) {
|
|
1046
1056
|
console.log(JSON.stringify(rows, null, 2));
|
|
1047
1057
|
} else if (rows.length === 0) {
|
|
@@ -1110,6 +1120,7 @@ async function dbCommand(args) {
|
|
|
1110
1120
|
const stmt = db.prepare(trimmed);
|
|
1111
1121
|
if (stmt.reader) {
|
|
1112
1122
|
const rows = stmt.all();
|
|
1123
|
+
decodeCompressedTextInRows(rows);
|
|
1113
1124
|
if (rows.length === 0) console.log('(no results)');
|
|
1114
1125
|
else console.table(rows);
|
|
1115
1126
|
} else {
|
|
@@ -1166,7 +1177,7 @@ async function dbCommand(args) {
|
|
|
1166
1177
|
// to the subcommand. Each subcommand parses these flags position-independently,
|
|
1167
1178
|
// so they behave the same before or after the verb.
|
|
1168
1179
|
const SUBCOMMANDS = new Set([
|
|
1169
|
-
'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
|
|
1180
|
+
'db', 'themes', 'docs', 'sync', 'memories', 'instances', 'memory-diff', 'recall-replay', 'anchor-probe', 'completion', 'logs', 'migrations', 'maintenance', 'file-verify',
|
|
1170
1181
|
]);
|
|
1171
1182
|
// Global flags that consume the following token as their value.
|
|
1172
1183
|
const GLOBAL_VALUE_FLAGS = new Set(['-p', '--port', '-d', '--data-dir', '-i', '--instance', '--passphrase']);
|
|
@@ -1238,6 +1249,12 @@ if (subName === 'db') {
|
|
|
1238
1249
|
console.error(`Error: ${err.message}`);
|
|
1239
1250
|
process.exit(1);
|
|
1240
1251
|
});
|
|
1252
|
+
} else if (subName === 'anchor-probe') {
|
|
1253
|
+
const { anchorProbeCommand } = require('../lib/anchor-probe-command');
|
|
1254
|
+
anchorProbeCommand(subArgs).catch(err => {
|
|
1255
|
+
console.error(`Error: ${err.message}`);
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
});
|
|
1241
1258
|
} else if (subName === 'completion') {
|
|
1242
1259
|
const { completionCommand } = require('../lib/completion-commands');
|
|
1243
1260
|
completionCommand(subArgs).catch(err => {
|
|
@@ -118,6 +118,7 @@ const HELP_SOURCES = {
|
|
|
118
118
|
'file-verify': ['lib/file-verify-commands.js', 'printHelp'],
|
|
119
119
|
'memory-diff': ['lib/memory-diff-command.js', 'printMemoryDiffHelp'],
|
|
120
120
|
'recall-replay': ['lib/recall-replay-command.js', 'printRecallReplayHelp'],
|
|
121
|
+
'anchor-probe': ['lib/anchor-probe-command.js', 'printAnchorProbeHelp'],
|
|
121
122
|
completion: ['lib/completion-commands.js', 'printCompletionHelp'],
|
|
122
123
|
};
|
|
123
124
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quilltap memories maintain` and `validate --repair`: flag parsing, phase
|
|
3
|
+
* validation, request bodies, report rendering, and the integrity scan.
|
|
4
|
+
*
|
|
5
|
+
* @jest-environment node
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { parseFlags, renderRepairText } = require('../memories-commands');
|
|
11
|
+
const {
|
|
12
|
+
parsePhases,
|
|
13
|
+
buildMaintainBody,
|
|
14
|
+
renderMaintenanceReport,
|
|
15
|
+
renderReportsTable,
|
|
16
|
+
} = require('../memories-maintain-command');
|
|
17
|
+
const { scanDigestIntegrity } = require('../graph-integrity');
|
|
18
|
+
|
|
19
|
+
const ID = '11111111-2222-3333-4444-555555555555';
|
|
20
|
+
|
|
21
|
+
describe('memories maintain flags', () => {
|
|
22
|
+
it('parses verb, character, phases, dry-run, max, threshold, cap and port', () => {
|
|
23
|
+
const { flags, positional } = parseFlags([
|
|
24
|
+
'maintain', '--instance', 'Friday', '--character', 'Friday', '--phases', 'consolidate,demote',
|
|
25
|
+
'--dry-run', '--max', '12', '--threshold', '0.8', '--cap', '500', '--port', '3005', '--json',
|
|
26
|
+
]);
|
|
27
|
+
expect(positional).toEqual(['maintain']);
|
|
28
|
+
expect(flags).toMatchObject({
|
|
29
|
+
instance: 'Friday', character: 'Friday', phases: 'consolidate,demote', dryRun: true,
|
|
30
|
+
max: 12, threshold: 0.8, cap: 500, port: 3005, json: true,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('leaves the port undefined so the instance lock can supply it', () => {
|
|
35
|
+
expect(parseFlags(['maintain', '--character', 'X']).flags.port).toBeUndefined();
|
|
36
|
+
expect(parseFlags(['maintain', '-p', '3007']).flags.port).toBe(3007);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('parses --repair and --write for validate', () => {
|
|
40
|
+
const { flags } = parseFlags(['validate', '--repair', '--write']);
|
|
41
|
+
expect(flags).toMatchObject({ repair: true, write: true });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('parsePhases', () => {
|
|
46
|
+
it('returns null for every phase', () => {
|
|
47
|
+
expect(parsePhases('')).toBeNull();
|
|
48
|
+
expect(parsePhases(undefined)).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('trims and drops duplicates, leaving names and order to the server', () => {
|
|
52
|
+
expect(parsePhases('mirror, demote,fold-near-duplicates,demote')).toEqual(['mirror', 'demote', 'fold-near-duplicates']);
|
|
53
|
+
// No local phase list: an unknown name is the server's 400 to report.
|
|
54
|
+
expect(parsePhases('consolidate,housekeep')).toEqual(['consolidate', 'housekeep']);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('buildMaintainBody', () => {
|
|
59
|
+
it('defaults to a real run of every phase with no overrides', () => {
|
|
60
|
+
const { flags } = parseFlags(['maintain', '--character', 'X']);
|
|
61
|
+
expect(buildMaintainBody(ID, flags)).toEqual({ characterId: ID, dryRun: false });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('maps the knobs onto settings', () => {
|
|
65
|
+
const { flags } = parseFlags([
|
|
66
|
+
'maintain', '--dry-run', '--phases', 'consolidate', '--max', '5', '--threshold', '0.65', '--cap', '300',
|
|
67
|
+
]);
|
|
68
|
+
expect(buildMaintainBody(ID, flags)).toEqual({
|
|
69
|
+
characterId: ID,
|
|
70
|
+
dryRun: true,
|
|
71
|
+
phases: ['consolidate'],
|
|
72
|
+
settings: { maxClustersPerRun: 5, consolidation: { clusterThreshold: 0.65 }, cap: 300 },
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('omits characterId for a user-wide run', () => {
|
|
77
|
+
const { flags } = parseFlags(['maintain', '--character', 'all']);
|
|
78
|
+
expect(buildMaintainBody(null, flags)).toEqual({ dryRun: false });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('allows a user-wide dry run of the model-free phases', () => {
|
|
82
|
+
const { flags } = parseFlags(['maintain', '--dry-run', '--phases', 'demote,retire']);
|
|
83
|
+
expect(buildMaintainBody(null, flags)).toEqual({ dryRun: true, phases: ['demote', 'retire'] });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('passes bounds through for the server to judge (no local copy of its limits)', () => {
|
|
87
|
+
expect(buildMaintainBody(ID, parseFlags(['maintain', '--cap', '50']).flags)).toMatchObject({ settings: { cap: 50 } });
|
|
88
|
+
expect(buildMaintainBody(ID, parseFlags(['maintain', '--max', '5000']).flags)).toMatchObject({ settings: { maxClustersPerRun: 5000 } });
|
|
89
|
+
expect(buildMaintainBody(null, parseFlags(['maintain', '--dry-run']).flags)).toEqual({ dryRun: true });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('renderMaintenanceReport', () => {
|
|
94
|
+
function capture(fn) {
|
|
95
|
+
let out = '';
|
|
96
|
+
fn((s) => { out += s; });
|
|
97
|
+
return out.replace(/\x1b\[[0-9;]*m/g, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('prints phase lines and every phase detail', () => {
|
|
101
|
+
const out = capture((write) => renderMaintenanceReport({
|
|
102
|
+
characterId: ID, characterName: 'Ariadne', dryRun: true, durationMs: 2500,
|
|
103
|
+
phases: [
|
|
104
|
+
{ phase: 'fold-near-duplicates', status: 'ran', durationMs: 10 },
|
|
105
|
+
{ phase: 'consolidate', status: 'ran', durationMs: 2000 },
|
|
106
|
+
{ phase: 'demote', status: 'ran', durationMs: 5 },
|
|
107
|
+
{ phase: 'retire', status: 'skipped', skippedBecause: 'no-retention-window', durationMs: 0 },
|
|
108
|
+
{ phase: 'mirror', status: 'ran', durationMs: 3 },
|
|
109
|
+
],
|
|
110
|
+
fold: {
|
|
111
|
+
eligible: 80, deferred: 0, groupsDeferred: 0, folded: 2, groupsFailed: 0,
|
|
112
|
+
groups: [{ survivorId: 'aaaaaaaa-1', absorbedIds: ['bbbbbbbb-2', 'cccccccc-3'], minCosine: 0.97 }],
|
|
113
|
+
},
|
|
114
|
+
consolidation: {
|
|
115
|
+
dryRun: true,
|
|
116
|
+
clusters: [{
|
|
117
|
+
bucket: { kind: 'other', subjectName: 'Calvin', subjectCharacterId: 'c' },
|
|
118
|
+
clusterKind: 'semantic', status: 'planned',
|
|
119
|
+
memberIds: ['aaaaaaaa-1', 'bbbbbbbb-2'], memberContents: ['Calvin likes tea', 'Calvin drinks tea daily'],
|
|
120
|
+
keepStandalone: ['bbbbbbbb-2'], contradictions: [{ olderId: 'aaaaaaaa-1', newerId: 'bbbbbbbb-2', note: 'tea vs coffee' }],
|
|
121
|
+
digests: [{ id: 'd1', action: 'create', content: 'Calvin is a devoted tea drinker', importance: 0.6, memberIds: ['aaaaaaaa-1'] }],
|
|
122
|
+
}],
|
|
123
|
+
stats: { candidates: 40, clustersFound: 3, clustersAttempted: 1, clustersDeferred: 2, durationMs: 1500 },
|
|
124
|
+
},
|
|
125
|
+
demote: {
|
|
126
|
+
cap: 1000, hotTotal: 1300, demotable: 1200, protected: 100, excess: 200, demoted: 200,
|
|
127
|
+
demotedIds: [], highestScoreDemoted: 0.4,
|
|
128
|
+
},
|
|
129
|
+
mirror: { subjects: ['self', 'Calvin'], filesWritten: 0, deferredToCommit: false },
|
|
130
|
+
}, write));
|
|
131
|
+
expect(out).toContain('Maintenance dry run');
|
|
132
|
+
expect(out).toContain('Ariadne');
|
|
133
|
+
expect(out).toContain('no-retention-window');
|
|
134
|
+
expect(out).toContain('aaaaaaaa <=');
|
|
135
|
+
expect(out).toContain('folded 2');
|
|
136
|
+
expect(out).toContain('about Calvin');
|
|
137
|
+
expect(out).toContain('Calvin likes tea');
|
|
138
|
+
expect(out).toContain('[kept standalone]');
|
|
139
|
+
expect(out).toContain('Calvin is a devoted tea drinker');
|
|
140
|
+
expect(out).toContain('tea vs coffee');
|
|
141
|
+
expect(out).toContain('candidates 40');
|
|
142
|
+
expect(out).toContain('cap 1000');
|
|
143
|
+
expect(out).toContain('demotable 1200');
|
|
144
|
+
expect(out).toContain('excess 200');
|
|
145
|
+
expect(out).toContain('self, Calvin');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reports a skipped run', () => {
|
|
149
|
+
const out = capture((write) => renderMaintenanceReport({
|
|
150
|
+
characterId: ID, characterName: null, dryRun: true, skippedReason: 'archived', phases: [],
|
|
151
|
+
}, write));
|
|
152
|
+
expect(out).toContain('Skipped: archived');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('reports a skipped consolidation', () => {
|
|
156
|
+
const out = capture((write) => renderMaintenanceReport({
|
|
157
|
+
characterId: ID, characterName: 'A', dryRun: true, phases: [],
|
|
158
|
+
consolidation: { dryRun: true, skippedReason: 'no-llm', clusters: [], stats: {} },
|
|
159
|
+
}, write));
|
|
160
|
+
expect(out).toContain('Skipped: no-llm');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('renderReportsTable', () => {
|
|
165
|
+
it('prints one row per character', () => {
|
|
166
|
+
let out = '';
|
|
167
|
+
renderReportsTable([
|
|
168
|
+
{
|
|
169
|
+
characterId: ID, characterName: 'Ariadne', dryRun: true,
|
|
170
|
+
phases: [{ phase: 'demote', status: 'ran', durationMs: 1 }, { phase: 'retire', status: 'ran', durationMs: 1 }],
|
|
171
|
+
demote: { cap: 1000, hotTotal: 10, demotable: 10, protected: 0, excess: 0, demoted: 0, demotedIds: [], highestScoreDemoted: null },
|
|
172
|
+
retire: { retentionDays: 90, retired: 3, retiredIds: [] },
|
|
173
|
+
},
|
|
174
|
+
{ characterId: 'ffffffff-0', characterName: null, dryRun: true, skippedReason: 'archived', phases: [] },
|
|
175
|
+
], (s) => { out += s; });
|
|
176
|
+
out = out.replace(/\x1b\[[0-9;]*m/g, '');
|
|
177
|
+
expect(out).toContain('across 2 characters');
|
|
178
|
+
expect(out).toMatch(/Ariadne\s+demote,retire\s+-\s+10\/1000 -0\s+3/);
|
|
179
|
+
expect(out).toContain('ffffffff');
|
|
180
|
+
expect(out).toContain('skipped: archived');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('scanDigestIntegrity', () => {
|
|
185
|
+
// better-sqlite3 is mocked under jest, so drive the scanner with a fake
|
|
186
|
+
// that answers the handful of queries it issues.
|
|
187
|
+
function fakeDb(rows, columns) {
|
|
188
|
+
const has = new Set(columns);
|
|
189
|
+
const scoped = (sql, params) => (sql.includes('characterId = ?') ? rows.filter((r) => r.characterId === params[0]) : rows);
|
|
190
|
+
return {
|
|
191
|
+
prepare(sql) {
|
|
192
|
+
return {
|
|
193
|
+
all: (...params) => {
|
|
194
|
+
if (sql.startsWith('PRAGMA')) return [...has].map((name) => ({ name }));
|
|
195
|
+
if (sql.trim() === 'SELECT id FROM memories') return rows.map((r) => ({ id: r.id }));
|
|
196
|
+
if (sql.includes('consolidatedFrom FROM')) {
|
|
197
|
+
return scoped(sql, params).filter((r) => r.consolidatedFrom && r.consolidatedFrom !== '[]');
|
|
198
|
+
}
|
|
199
|
+
if (sql.includes('supersededById FROM')) return scoped(sql, params).filter((r) => r.supersededById != null);
|
|
200
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
201
|
+
},
|
|
202
|
+
get: (...params) => {
|
|
203
|
+
if (sql.includes('embedding IS NULL')) return { n: scoped(sql, params).filter((r) => r.embedding == null).length };
|
|
204
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const ALL_COLUMNS = ['id', 'characterId', 'relatedMemoryIds', 'consolidatedFrom', 'supersededById', 'embedding'];
|
|
212
|
+
|
|
213
|
+
it('counts dangling digest members, dangling supersededById and missing embeddings', () => {
|
|
214
|
+
const db = fakeDb([
|
|
215
|
+
{ id: 'digest', characterId: 'c1', consolidatedFrom: JSON.stringify(['m1', 'gone1', 'gone2']), supersededById: null, embedding: 'x' },
|
|
216
|
+
{ id: 'm1', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'digest', embedding: 'x' },
|
|
217
|
+
{ id: 'm2', characterId: 'c1', consolidatedFrom: '[]', supersededById: 'gone-digest', embedding: null },
|
|
218
|
+
{ id: 'other', characterId: 'c2', consolidatedFrom: JSON.stringify(['gone3']), supersededById: null, embedding: null },
|
|
219
|
+
], ALL_COLUMNS);
|
|
220
|
+
|
|
221
|
+
const one = scanDigestIntegrity(db, { characterId: 'c1', includePairs: true });
|
|
222
|
+
expect(one.consolidatedFrom).toEqual({ links: 2, digests: 1 });
|
|
223
|
+
expect(one.supersededBy).toBe(1);
|
|
224
|
+
expect(one.missingEmbeddings).toBe(1);
|
|
225
|
+
expect(one.consolidatedFromPairs).toEqual([{ digestId: 'digest', characterId: 'c1', memberIds: ['gone1', 'gone2'] }]);
|
|
226
|
+
expect(one.supersededByPairs).toEqual([{ memberId: 'm2', characterId: 'c1', digestId: 'gone-digest' }]);
|
|
227
|
+
|
|
228
|
+
const all = scanDigestIntegrity(db);
|
|
229
|
+
expect(all.consolidatedFrom).toEqual({ links: 3, digests: 2 });
|
|
230
|
+
expect(all.missingEmbeddings).toBe(2);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('reads an instance without the tier-era columns as nothing to scan', () => {
|
|
234
|
+
const db = fakeDb([{ id: 'm1', characterId: 'c1', embedding: null }], ['id', 'characterId', 'relatedMemoryIds', 'embedding']);
|
|
235
|
+
expect(scanDigestIntegrity(db)).toEqual({
|
|
236
|
+
consolidatedFrom: { links: 0, digests: 0 }, supersededBy: 0, missingEmbeddings: 1,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('renderRepairText', () => {
|
|
242
|
+
function captureLog(fn) {
|
|
243
|
+
const lines = [];
|
|
244
|
+
const spy = jest.spyOn(console, 'log').mockImplementation((...args) => { lines.push(args.join(' ')); });
|
|
245
|
+
try { fn(); } finally { spy.mockRestore(); }
|
|
246
|
+
return lines.join('\n').replace(/\x1b\[[0-9;]*m/g, '');
|
|
247
|
+
}
|
|
248
|
+
const found = {
|
|
249
|
+
related: { links: 3, rows: 2 }, consolidatedFrom: { links: 1, digests: 1 }, supersededBy: 2, missingEmbeddings: 4,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it('prints a dry run with the --write hint', () => {
|
|
253
|
+
const out = captureLog(() => renderRepairText({ dryRun: true, characterId: null, found }));
|
|
254
|
+
expect(out).toContain('dry run, every memory');
|
|
255
|
+
expect(out).toContain('3 dangling related link(s) on 2 row(s)');
|
|
256
|
+
expect(out).toContain('1 dangling consolidatedFrom member(s) on 1 digest(s)');
|
|
257
|
+
expect(out).toContain('2 dangling supersededById');
|
|
258
|
+
expect(out).toContain('4 row(s) without an embedding');
|
|
259
|
+
expect(out).toContain('Add --write');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('prints the repaired counts and a skipped embedding pass', () => {
|
|
263
|
+
const out = captureLog(() => renderRepairText({
|
|
264
|
+
dryRun: false, characterId: ID, found,
|
|
265
|
+
repaired: {
|
|
266
|
+
relatedRowsTouched: 2, digestsTouched: 1, membersCut: 2, charactersAffected: 1,
|
|
267
|
+
embeddingJobsQueued: 0, embeddingSkippedReason: 'no-embedding-profile',
|
|
268
|
+
},
|
|
269
|
+
}));
|
|
270
|
+
expect(out).toContain('(applied)');
|
|
271
|
+
expect(out).toContain('2 row(s) relinked, 1 digest(s) touched, 2 member(s) cut loose');
|
|
272
|
+
expect(out).toContain('0 embedding job(s) queued');
|
|
273
|
+
expect(out).toContain('no-embedding-profile');
|
|
274
|
+
expect(out).not.toContain('Add --write');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quilltap migrations status` compares the ledger against the ids it reads
|
|
3
|
+
* out of `migrations/scripts/*.ts`. Most migrations declare their id through a
|
|
4
|
+
* `MIGRATION_ID` constant rather than a string literal; when the reader only
|
|
5
|
+
* understood literals, every one of those vanished from "source" and showed up
|
|
6
|
+
* as "retired" on any instance that had run it.
|
|
7
|
+
*
|
|
8
|
+
* @jest-environment node
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const { extractMigrationId } = require('../migrations-commands');
|
|
17
|
+
|
|
18
|
+
describe('extractMigrationId', () => {
|
|
19
|
+
it('reads a string-literal id', () => {
|
|
20
|
+
expect(extractMigrationId(`export const m = {\n id: 'foo-v1',\n};`)).toBe('foo-v1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('resolves an id declared through MIGRATION_ID', () => {
|
|
24
|
+
const src = `const MIGRATION_ID = 'bar-v1';\nexport const m = {\n id: MIGRATION_ID,\n};`;
|
|
25
|
+
expect(extractMigrationId(src)).toBe('bar-v1');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('accepts a typed MIGRATION_ID constant', () => {
|
|
29
|
+
const src = `const MIGRATION_ID: string = 'baz-v1'\nexport const m = {\n id: MIGRATION_ID,\n};`;
|
|
30
|
+
expect(extractMigrationId(src)).toBe('baz-v1');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns null when no id is declared', () => {
|
|
34
|
+
expect(extractMigrationId('export const nothing = 1;')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('every migration script yields an id', () => {
|
|
39
|
+
const scriptsDir = path.join(__dirname, '..', '..', '..', '..', 'migrations', 'scripts');
|
|
40
|
+
const files = fs.existsSync(scriptsDir)
|
|
41
|
+
? fs.readdirSync(scriptsDir).filter((f) => f.endsWith('.ts') && f !== 'index.ts')
|
|
42
|
+
: [];
|
|
43
|
+
|
|
44
|
+
it('finds the migration scripts', () => {
|
|
45
|
+
expect(files.length).toBeGreaterThan(100);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('extracts an id from each one', () => {
|
|
49
|
+
const missing = files.filter(
|
|
50
|
+
(f) => !extractMigrationId(fs.readFileSync(path.join(scriptsDir, f), 'utf8'))
|
|
51
|
+
);
|
|
52
|
+
expect(missing).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|