From d816f907fa398ac4381444156121969f57df7426 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau Date: Wed, 2 Sep 2026 21:27:43 -0500 Subject: [PATCH 1/4] Add manual testing checklists for retail and retail-joins datasets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split manualTesting.md (untracked) into two standalone, full-surface checklists — one per dataset. The retail-joins file centers on JOINs: index-free small-RHS joins, the 'join needs an index' error → ADVISE → apply flow (25 reserved customers, 24 Seattle inventory holes, per-store order counts), join aggregates, and the stock_value known issue. All Expect values verified live against freshly loaded default-scale stores. --- manualTesting.retail-joins.md | 649 ++++++++++++++++++++++++++++++++++ manualTesting.retail.md | 628 ++++++++++++++++++++++++++++++++ 2 files changed, 1277 insertions(+) create mode 100644 manualTesting.retail-joins.md create mode 100644 manualTesting.retail.md diff --git a/manualTesting.retail-joins.md b/manualTesting.retail-joins.md new file mode 100644 index 0000000..0537c02 --- /dev/null +++ b/manualTesting.retail-joins.md @@ -0,0 +1,649 @@ +# Manual testing — `dittosh` against the retail-joins dataset + +Full-surface checklist for the `dittosh dql` feature set, focused on JOINs. +`retail-joins` is the normalized variant of `retail`: `orders`/ +`order_items` are stripped of denormalized fields (`store_name`, +`customer_name`, …) so you must join to get readable results, a +`product_types` table is added (32 fixed rows), ~8% of inventory +(store, product) pairs are dropped and ~1% of customers are reserved with no +orders — deliberate holes for LEFT JOIN anti-join queries. + +Every command is copy-pasteable and uses the default data dir. Run top to +bottom; check off what passes. Note anything off (rendering glitches, wrong +exit codes, noise on stdout) as a comment under the failing test. + +Sibling file: `manualTesting.retail.md` covers the same feature surface +against the denormalized `retail` dataset (no JOINs needed). + +Prereq: the release build is installed (`scripts/install-release.sh`). + +Two things that are **not** bugs: + +- The ~7 `warning:`/`INFO` lines at startup are the SDK's native tracing + bootstrap writing to **stderr** (fd-level, not suppressible from JS). + stdout — everything you pipe — stays clean. +- Piped stdout is always **JSON**, never the table. The table (and the + pager) only appear when stdout is a terminal. + +Anchor rows to know (deterministic, present at any scale): + +- Customer **Jordan Anchor** — `_id`/`customer_id` + `d30977d3-fa5d-4e13-9175-f637bccc4c87`, email `jordan.anchor@example.net`, + home store `store_seattle`. +- His three Seattle orders — `order_20221209_0001`, `order_20230110_0001`, + `order_20230615_0001` — each 2 items, `total` 164.25. + +## 1. Setup + +- [ ] **Start from a clean store** (destroys any existing local store — + e.g. the retail one from the sibling checklist) + ```bash + dittosh dql delete-store -y + ``` + Expect: `Deleted the store at …` (or `No store at … — nothing to delete.`). + +- [ ] **Inspect the retail-joins suite** + ```bash + dittosh dql dataset show retail-joins + ``` + Expect: 8 collections (stores, categories, product_types, products, + customers, inventory, orders, order_items), scaling dimension `orders`, + default 5,000, 96 catalog queries, and a known-issues note about + `joins__left__products_inventory_stock_value`. + +- [ ] **Load it** + ```bash + dittosh dql dataset load retail-joins + ``` + Expect: progress on **stderr**, a clean summary table; exits 0. + +- [ ] **Sanity counts** + ```bash + dittosh dql "SELECT count(*) AS n FROM orders" + dittosh dql "SELECT count(*) AS n FROM product_types" + dittosh dql "SELECT count(*) AS n FROM products" + dittosh dql "SELECT count(*) AS n FROM customers" + ``` + Expect (piped → JSON): `5000`, `32`, `400`, `1251`. + +- [ ] **Normalization proof — the denormalized fields are gone** + ```bash + dittosh dql "SELECT * FROM orders LIMIT 1" + ``` + Expect: 10 fields only (`_id`, `customer_id`, `deleted`, `item_count`, + `order_date`, `order_id`, `status`, `store_id`, `subtotal`, `total`) — no + `store_name`/`customer_name`/`customer_email`. To see a store name you + must join for it. + +## 2. Joins that work without indexes (small right-hand side) + +Joining to a *small* collection (stores: 8, product_types: 32, products: +400) needs no index. Joining to a large one does — that's section 3. + +- [ ] **Inner join: the anchor order's store** + ```bash + dittosh dql "SELECT o._id, o.total, s.store_name, s.location.city + FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id + WHERE o._id = 'order_20221209_0001'" + ``` + Expect: 1 row — `order_20221209_0001`, 164.25, `Zava Retail Seattle`, + `Seattle`. + +- [ ] **Inner join: the anchor order's line items** + ```bash + dittosh dql "SELECT i._id, i.quantity, p.product_name, p.base_price + FROM order_items AS i INNER JOIN products AS p ON i.product_id = p._id + WHERE i.order_id = 'order_20221209_0001'" + ``` + Expect: 2 rows — `HND item 0001` (52.07) and `HND item 0002` (69.66). + +- [ ] **Three-way join: Jordan Anchor's order history** + ```bash + dittosh dql "SELECT o._id, o.total, c.last_name, s.store_name + FROM orders AS o + INNER JOIN customers AS c ON c._id = o.customer_id + INNER JOIN stores AS s ON s._id = o.store_id + WHERE o.customer_id = 'd30977d3-fa5d-4e13-9175-f637bccc4c87'" + ``` + Expect: 3 rows, one per anchor order — `Anchor`, `Zava Retail Seattle`, + 164.25 each. + +- [ ] **Join with the new product_types table** + ```bash + dittosh dql "SELECT count(*) AS n FROM products AS p INNER JOIN product_types AS t ON p.type_id = t._id WHERE p.deleted = false" + ``` + Expect: `[{"n":400}]` — every product has a type. + +- [ ] **Customers and their home stores** + ```bash + dittosh dql "SELECT count(*) AS n FROM customers AS c INNER JOIN stores AS s ON c.primary_store_id = s._id WHERE c.deleted = false" + ``` + Expect: `[{"n":1251}]`. + +## 3. Joins to large collections need indexes (the ADVISE story) + +Joining to a large collection without an index on the join predicate is a +hard error — and the error itself tells you what to do next. + +- [ ] **A LEFT JOIN against orders fails clean** + ```bash + dittosh dql "SELECT c._id, c.first_name, c.last_name FROM customers AS c + LEFT OUTER JOIN orders AS o ON o.customer_id = c._id + WHERE o._id IS MISSING AND c.deleted = false" ; echo "exit: $?" + ``` + Expect: `Query error [query/evaluation]: Query failed: ` + "`Joining to + "o" disallowed without appropriate index support. Please run ADVISE for + recommendations.`", `exit: 1`. + +- [ ] **ADVISE reads the join and recommends both indexes** + ```bash + dittosh dql "SELECT c._id, c.first_name, c.last_name FROM customers AS c + LEFT OUTER JOIN orders AS o ON o.customer_id = c._id + WHERE o._id IS MISSING AND c.deleted = false" --advise + ``` + Expect: two suggestions — + `customers — equality predicates on deleted` and + `orders — equality predicates on customer_id; supports join` — + plus the copy-pasteable `apply with:` line carrying the full statement. + +- [ ] **Apply them** + ```bash + dittosh dql "SELECT c._id, c.first_name, c.last_name FROM customers AS c + LEFT OUTER JOIN orders AS o ON o.customer_id = c._id + WHERE o._id IS MISSING AND c.deleted = false" --advise --apply -y + ``` + Expect: both suggestions badge `✓ created` + (`adv_customers_deleted`, `adv_orders_customer_id`). + +- [ ] **Re-run: the anti-join finds the reserved customers** + ```bash + dittosh dql "SELECT c._id, c.first_name, c.last_name FROM customers AS c + LEFT OUTER JOIN orders AS o ON o.customer_id = c._id + WHERE o._id IS MISSING AND c.deleted = false" + ``` + Expect: **25 rows** — the ~1% of customers the generator deliberately + never gave orders. + +- [ ] **EXPLAIN shows the nested-loop join over the new index** + ```bash + dittosh dql "SELECT c._id FROM customers AS c LEFT OUTER JOIN orders AS o ON o.customer_id = c._id WHERE o._id IS MISSING AND c.deleted = false" --explain + ``` + Expect: `indexScan` on `adv_customers_deleted`, then an + `nlJoin … outer=true` whose inner side is a covering `indexScan` on + `adv_orders_customer_id`, then the `IS MISSING` filter. + +- [ ] **Per-store order counts (LEFT JOIN with GROUP BY)** + ```bash + dittosh dql "SELECT s.store_name, COUNT(o._id) AS order_count + FROM stores AS s LEFT OUTER JOIN orders AS o ON o.store_id = s._id + WHERE s.deleted = false GROUP BY s._id, s.store_name ORDER BY s.store_name" --advise --apply -y + ``` + Expect: on the first run, ADVISE creates `adv_stores_deleted` and + `adv_orders_store_id`; re-run the same statement without the flags and + you get **8 rows** — Zava Online 1268, Bellevue 418, Olympia 448, + Redmond 384, Seattle 1259, Spokane 425, Tacoma 407, Vancouver 391 + (sums to 5,000). + +- [ ] **Inventory holes at one store (AND in the ON clause)** + ```bash + dittosh dql "SELECT p._id, p.product_name FROM products AS p + LEFT OUTER JOIN inventory AS i ON i.product_id = p._id AND i.store_id = 'store_seattle' + WHERE i._id IS MISSING" --advise --apply -y + ``` + Expect: ADVISE creates a *composite* index + `adv_inventory_product_id_store_id ON inventory (product_id ASC, store_id ASC)` + — one per ON predicate. Re-run without the flags: **24 rows** — the ~8% + of (store, product) inventory pairs the generator drops. + +## 4. Aggregates over joins + +- [ ] **Sales by store by month** (verbatim catalog query) + ```bash + dittosh dql "SELECT s.store_name, substr(o.order_date, 1, 7) AS month, SUM(o.total) AS total + FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id + GROUP BY s.store_name, substr(o.order_date, 1, 7) ORDER BY s.store_name, month" + ``` + Expect: **248 rows** (8 stores × ~31 months). + +- [ ] **Big spenders (HAVING)** + ```bash + dittosh dql "SELECT c._id FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c._id + GROUP BY c._id HAVING SUM(o.total) > 5000" + ``` + Expect: **202 rows** (works without an index — customers is small). + +## 5. The catalog runner + +- [ ] **Run a catalog join by name, with its index setup** + ```bash + dittosh dql dataset run items__join__products --dataset retail-joins --setup + ``` + Expect: `Running items__join__products (retail-joins):`, the query text, + the setup DDL (`DROP INDEX IF EXISTS items_product…` / `CREATE INDEX + items_product…`) each acked `OK`, then the 2 anchor line items. + postQueries (teardown) never run — created indexes are kept. + +- [ ] **Known issue, do not demo**: `joins__left__products_inventory_stock_value` + hangs (nlJoin over intersectScan) on SDK 5.1.0 when its `inv_store_flat` + index exists. If you try it, run it *without* `--setup`, or add `LIMIT`. + +## 6. Housekeeping commands + +- [ ] **Doctor** + ```bash + dittosh dql doctor + ``` + Expect: six `✓` lines — platform, node, data directory, token, sdk, lock. + +- [ ] **Version** + ```bash + dittosh version + dittosh version --format json | jq '.ditto_sdk' + ``` + Expect: aligned key/value list; the JSON form reports `"5.1.0"`. + +- [ ] **Collections** + ```bash + dittosh dql collections + ``` + Expect: the 8 retail-joins collections plus the `__feature_flags` system + collection. + +- [ ] **Indexes — the ones you created in section 3** + ```bash + dittosh dql indexes + dittosh dql indexes orders + ``` + Expect: the `adv_*` indexes (`adv_customers_deleted`, + `adv_orders_customer_id`, `adv_stores_deleted`, `adv_orders_store_id`, + `adv_inventory_product_id_store_id`, maybe `items_product` from section + 5); the second command filters to `orders` only. + +- [ ] **Update check** (optional — hits the network) + ```bash + dittosh update --check + ``` + Expect: "Already up to date" or "Update available: …". + +- [ ] **Skills list** (optional, read-only) + ```bash + dittosh skills list + ``` + Expect: a table of AI agents and whether the DQL skill is installed. + +- [ ] **Global flags: colors off** + ```bash + dittosh dql "SELECT * FROM product_types" --no-color + ``` + Expect: the table renders with zero ANSI color escapes. + +## 7. Statement input modes + +- [ ] **`-e/--execute` form** + ```bash + dittosh dql -e "SELECT count(*) AS n FROM product_types" + ``` + Expect: `[{"n":32}]`. + +- [ ] **Batch from a file** + ```bash + printf "SELECT count(*) AS n FROM stores;\nSELECT count(*) AS n FROM product_types;\n" > /tmp/mtj-batch.sql + dittosh dql -f /tmp/mtj-batch.sql + ``` + Expect: two JSON arrays (`8` then `32`), summary `2 ok, 0 failed (of 2)` + on stderr. + +- [ ] **Batch from stdin + `--continue-on-error`** + ```bash + printf "SELECT count(*) AS n FROM stores;\nSELEC broken;\nSELECT count(*) AS n FROM categories;\n" | dittosh dql --continue-on-error ; echo "exit: $?" + ``` + Expect: the two good results on stdout, `Query error [query/invalid]` on + stderr, summary `2 ok, 1 failed (of 3)`, `exit: 1`. + +- [ ] **REPL dot-commands are stripped from batches** + ```bash + printf ".exit\nSELECT count(*) AS n FROM stores;\n" | dittosh dql + ``` + Expect: stderr note `skipping REPL command in batch input: .exit`, then + the result — a batch never exits mid-stream. + +- [ ] **`-p/--param` binding, into a join** + ```bash + dittosh dql "SELECT c.first_name, c.last_name, s.store_name + FROM customers AS c INNER JOIN stores AS s ON c.primary_store_id = s._id + WHERE c._id = :cid" -p cid=d30977d3-fa5d-4e13-9175-f637bccc4c87 + ``` + Expect: one row — `Jordan`, `Anchor`, `Zava Retail Seattle`. + +- [ ] **`--args` inline JSON** + ```bash + dittosh dql "SELECT o._id, o.total FROM orders AS o WHERE o.customer_id = :cid ORDER BY o._id" --args '{"cid":"d30977d3-fa5d-4e13-9175-f637bccc4c87"}' + ``` + Expect: the 3 anchor orders, 164.25 each. + +- [ ] **Multiple statements in argv are refused** + ```bash + dittosh dql "SELECT * FROM stores; SELECT * FROM categories" ; echo "exit: $?" + ``` + Expect: `trailing text after the statement is not executable…`, + `exit: 2`. + +## 8. Table display on a TTY + +- [ ] **Join results fit the window** + ```bash + dittosh dql "SELECT o._id, o.order_date, o.total, c.first_name, c.last_name, c.email + FROM orders AS o INNER JOIN customers AS c ON c._id = o.customer_id LIMIT 5" + ``` + Expect: the table is exactly your terminal width — no wrapping. Long + values (emails, UUIDs) end with `…`. `total` right-aligns. `5 rows` + footer. + +- [ ] **Aggregate result columns** + ```bash + dittosh dql "SELECT s.store_name, COUNT(o._id) AS order_count + FROM stores AS s LEFT OUTER JOIN orders AS o ON o.store_id = s._id + WHERE s.deleted = false GROUP BY s._id, s.store_name ORDER BY s.store_name" + ``` + Expect: 8 rows, generous column widths, counts right-aligned. + +- [ ] **Resize resilience** — re-run the first query in a ~60-col window. + Expect: still fits, headers ellipsize rather than breaking layout. + +## 9. Pager + +- [ ] **Long results page** + ```bash + dittosh dql "SELECT * FROM orders" + ``` + Expect: opens in `less` (5,000 rows). `q` quits. + +- [ ] **Opt-out flag** + ```bash + dittosh dql "SELECT * FROM orders" --no-pager | wc -l + ``` + Expect: `60002` — normalized orders have 10 fields (12 JSON lines each), + so 5,000 × 12 + 2 bracket lines. (Denormalized retail orders: 75,002.) + +- [ ] **Opt-out env var** + ```bash + DITTOSH_NO_PAGER=1 dittosh dql "SELECT * FROM orders" | wc -l + ``` + Expect: same — `60002`, no pager. + +- [ ] **Short results never page** + ```bash + dittosh dql "SELECT * FROM product_types" + ``` + Expect: prints inline (32 rows), no pager flash. + +## 10. Output formats & export + +- [ ] **Vertical mode: a joined row as a record block** + ```bash + dittosh dql "SELECT o._id, o.total, s.store_name, s.location.city, s.location.state + FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id + WHERE o._id = 'order_20221209_0001'" --format vertical + ``` + Expect: one `── row 1 ──` block, `field │ value` lines, nothing + truncated. + +- [ ] **Markdown to stdout** + ```bash + dittosh dql "SELECT type_name, category_id FROM product_types WHERE category_id = 'cat_paint'" --format markdown + ``` + Expect: a GFM table of the 4 paint types. + +- [ ] **Markdown to file (extension inference)** + ```bash + dittosh dql "SELECT * FROM products WHERE base_price > 500" -o /tmp/expensive.md + ``` + Expect: `Wrote 12 rows … (markdown)`. + +- [ ] **HTML report of a join** + ```bash + dittosh dql "SELECT o._id, o.order_date, o.total, c.first_name, c.last_name, s.store_name + FROM orders AS o + INNER JOIN customers AS c ON c._id = o.customer_id + INNER JOIN stores AS s ON s._id = o.store_id + WHERE o.customer_id = 'd30977d3-fa5d-4e13-9175-f637bccc4c87'" -o /tmp/anchor-orders.html + open /tmp/anchor-orders.html + ``` + Expect: `Wrote 3 rows … (html)`; styled table, no external assets. + +- [ ] **JSON and CSV to file** + ```bash + dittosh dql "SELECT * FROM product_types" -o /tmp/types.json + dittosh dql "SELECT * FROM product_types" -o /tmp/types.csv + ``` + Expect: valid JSON array (32 entries) / RFC-4180 CSV with header row. + +- [ ] **Files keep full fidelity** (no `…` truncation) + ```bash + dittosh dql "SELECT * FROM orders LIMIT 5" -o /tmp/orders.txt + ``` + Expect: a plain table with complete values — ellipsization is TTY-only. + +- [ ] **Explicit format beats extension** + ```bash + dittosh dql "SELECT * FROM product_types" -o /tmp/types.txt --format csv + ``` + Expect: CSV content in a `.txt` file. + +- [ ] **`-o` rejected for mutations/DDL** + ```bash + dittosh dql "INSERT INTO stores DOCUMENTS ({'_id':'x'})" -o /tmp/nope.json ; echo "exit: $?" + ``` + Expect: `-o/--out only applies to row-producing statements…`, `exit: 2`. + +## 11. Safety rails + +- [ ] **No-LIMIT heads-up** (once per config dir, TTY stderr only) + ```bash + DITTOSH_CONFIG_DIR=/tmp/mtj-fresh-config dittosh dql "SELECT * FROM product_types" + ``` + Expect, before the table: `heads up: this SELECT has no LIMIT — … + (shown once)`. Re-run: no warning. + +- [ ] **`--max-rows` truncates with a warning** + ```bash + dittosh dql "SELECT * FROM product_types" --max-rows 3 + ``` + Expect: 3 rows, and on stderr: `showing first 3 of 32 rows — add a LIMIT + clause`. + +## 12. Import external data (`dql import`) + +The standard import format is a **JSON array of objects**; NDJSON (one +object per line) is also accepted. `_id` is optional — docs without one get +a generated UUID. Imports upsert (`ON ID CONFLICT DO UPDATE`), so files +*with* `_id`s re-import cleanly; files *without* duplicate on re-import. + +- [ ] **Import a JSON array** + ```bash + cat > /tmp/import-j.json <<'EOF' + [ + { "_id": "imp_1", "type_name": "Imported Widget", "category_id": "cat_hardware" }, + { "_id": "imp_2", "type_name": "Imported Gizmo", "category_id": "cat_electrical" } + ] + EOF + dittosh dql import /tmp/import-j.json imported_types + dittosh dql "SELECT * FROM imported_types ORDER BY _id" + ``` + Expect: `Imported 2 documents into imported_types (…s)`; the 2 docs read + back intact. + +- [ ] **Re-import is idempotent** + ```bash + dittosh dql import /tmp/import-j.json imported_types + dittosh dql "SELECT count(*) AS n FROM imported_types" + ``` + Expect: still `[{"n":2}]`. + +- [ ] **Docs without `_id` get a generated UUID** + ```bash + echo '[{"name": "no id here"}]' > /tmp/noid.json + dittosh dql import /tmp/noid.json imported_misc + dittosh dql "SELECT _id, name FROM imported_misc" + ``` + Expect: the doc carries a UUID `_id`. + +- [ ] **NDJSON works too** + ```bash + printf '{"_id":"n1","v":1}\n{"_id":"n2","v":2}\n' > /tmp/import.ndjson + dittosh dql import /tmp/import.ndjson imported_nd + ``` + Expect: `Imported 2 documents into imported_nd`. + +- [ ] **Bad inputs exit 2, nothing written** + ```bash + dittosh dql import /tmp/missing.json things ; echo "exit: $?" + echo 'not json' > /tmp/bad.json && dittosh dql import /tmp/bad.json things ; echo "exit: $?" + dittosh dql import /tmp/import-j.json "bad;name" ; echo "exit: $?" + ``` + Expect: clear messages, all `exit: 2`. + +## 13. jq pipelines + +- [ ] **Join → jq** + ```bash + dittosh dql "SELECT o._id, o.total, s.store_name FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id WHERE o._id = 'order_20221209_0001'" | jq '.[0].store_name' + ``` + Expect: `"Zava Retail Seattle"` — jq parses cleanly (stdout is pure + JSON). + +- [ ] **Full round trip: query → jq → query** + ```bash + dittosh dql "SELECT _id FROM customers WHERE email = 'jordan.anchor@example.net'" \ + | jq '{cid: .[0]._id}' \ + | dittosh dql "SELECT o._id, o.total FROM orders AS o WHERE o.customer_id = :cid ORDER BY o._id" --args - + ``` + Expect: the 3 anchor orders, exit 0. + +- [ ] **Params from a file** + ```bash + echo '{"cid": "d30977d3-fa5d-4e13-9175-f637bccc4c87"}' > /tmp/params-j.json + dittosh dql "SELECT first_name, last_name FROM customers WHERE _id = :cid" --args @/tmp/params-j.json + ``` + Expect: the Jordan Anchor row. + +- [ ] **`-p` overrides `--args`** + ```bash + dittosh dql "SELECT store_name FROM stores WHERE location.city = :city" --args '{"city":"Bellevue"}' -p city=Tacoma + ``` + Expect: the Tacoma store, not Bellevue. + +- [ ] **Bad pipeline input fails clean** + ```bash + echo '[1,2]' | dittosh dql "SELECT * FROM stores" --args - ; echo "exit: $?" + ``` + Expect: `--args must be a JSON object` on stderr, `exit: 2`. + +## 14. REPL (interactive shell) + +- [ ] **Start it** + ```bash + dittosh dql + ``` + Expect: `dql>` prompt. + +- [ ] **In the shell (joins work here too):** + ```sql + SELECT * FROM product_types; + SELECT o._id, o.total, s.store_name FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id WHERE o._id = 'order_20221209_0001'; + .collections + .indexes orders + .help + ``` + Expect: fitted tables, a dim `(N ms)` timing note after every statement + (always on in the shell — no flag needed), listings, help. Long results + page through less. + +- [ ] **Multi-line statement** + ```sql + SELECT c.first_name, c.last_name, s.store_name + FROM customers AS c + INNER JOIN stores AS s ON c.primary_store_id = s._id + WHERE c._id = 'd30977d3-fa5d-4e13-9175-f637bccc4c87'; + ``` + Expect: continuation prompt until the `;`, then the Jordan Anchor row. + +- [ ] **Discard a half-typed statement** — start a multi-line statement, + then `.break` at the continuation prompt. Expect: buffer discarded, + nothing executed. + +- [ ] **Exit** with `.exit` — expect a clean return to your shell. + +## 15. Diagnostics (`--time` / `--explain` / `--profile` / `--advise`) + +- [ ] **Timing footer on a join** + ```bash + dittosh dql "SELECT o._id, s.store_name FROM orders AS o INNER JOIN stores AS s ON o.store_id = s._id WHERE o._id = 'order_20221209_0001'" --time + ``` + Expect: the row, then a dim `Time: N ms` footer on **stderr**. + +- [ ] **stdout stays clean (jq composability)** + ```bash + dittosh dql "SELECT count(*) AS n FROM orders" --time | jq '.[0].n' + ``` + Expect: `5000` — timing on stderr, pure JSON on stdout. + +- [ ] **Server breakdown with --profile** + ```bash + dittosh dql "SELECT c._id FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c._id GROUP BY c._id HAVING SUM(o.total) > 5000" --time --profile + ``` + Expect: the footer gains server-side timings — + `Time: N ms — server: elapsed … · parse … · plan …`. + +- [ ] **Profile a join aggregate** + ```bash + dittosh dql "SELECT c._id FROM orders AS o INNER JOIN customers AS c ON o.customer_id = c._id GROUP BY c._id HAVING SUM(o.total) > 5000" --profile + ``` + Expect: per-operator timings — `scan collection=orders · 5000 out`, an + `nlJoin … · 5000 in / 5000 out`, `▲ HOT` on the `groupBy` (`5000 in / + 1226 out`), `filter` down to `202`, and a `Results 202` summary. + +- [ ] **Per-statement timing in a batch** + ```bash + printf "SELECT count(*) FROM orders;\nSELECT count(*) FROM product_types;\n" | dittosh dql --time + ``` + Expect: one `Time: N ms` footer per statement, then the + `2 ok, 0 failed (of 2)` summary — all on stderr. + +- [ ] **EXPLAIN/ADVISE on joins** — covered where they're interesting: + section 3 (index requirement, `nlJoin` plans, multi-index advice). + +## 16. Exit codes & locking + +- [ ] **Query error → 1** + ```bash + dittosh dql "SELEC broken" ; echo "exit: $?" + ``` + +- [ ] **Missing join index → 1** (the section 3 error, before ADVISE is + applied — or after `delete-store` + reload) + +- [ ] **Usage error → 2** + ```bash + dittosh dql "SELECT * FROM stores" --format yaml ; echo "exit: $?" + ``` + +- [ ] **Success → 0** + ```bash + dittosh dql "SELECT * FROM product_types LIMIT 1" > /dev/null ; echo "exit: $?" + ``` + +- [ ] **Lock → 4** (optional, two terminals): start the REPL in one + terminal (`dittosh dql`), then in another: + ```bash + dittosh dql "SELECT * FROM stores LIMIT 1" ; echo "exit: $?" + ``` + Expect: a "in use by another dittosh process" message, `exit: 4`. + `.exit` the REPL and re-run — succeeds. + +## 17. Cleanup + +- [ ] **Delete the store when done** + ```bash + dittosh dql delete-store -y + ``` diff --git a/manualTesting.retail.md b/manualTesting.retail.md new file mode 100644 index 0000000..87a4449 --- /dev/null +++ b/manualTesting.retail.md @@ -0,0 +1,628 @@ +# Manual testing — `dittosh` against the retail dataset + +Full-surface checklist for the `dittosh dql` feature set. Every command is +copy-pasteable and uses the default data dir. Run top to bottom; check off +what passes. Note anything off (rendering glitches, wrong exit codes, noise +on stdout) as a comment under the failing test. + +Sibling file: `manualTesting.retail-joins.md` covers the same surface against +the normalized `retail-joins` dataset (JOINs, anti-joins, join indexes). + +Prereq: the release build is installed (`scripts/install-release.sh`). + +Two things that are **not** bugs: + +- The ~7 `warning:`/`INFO` lines at startup are the SDK's native tracing + bootstrap writing to **stderr** (fd-level, not suppressible from JS). + stdout — everything you pipe — stays clean. +- Piped stdout is always **JSON**, never the table. The table (and the + pager) only appear when stdout is a terminal. + +## 1. Setup + +- [ ] **Start from a clean store** (destroys any existing local store) + ```bash + dittosh dql delete-store -y + ``` + Expect: `Deleted the store at …` (or `No store at … — nothing to delete.`). + +- [ ] **List datasets** + ```bash + dittosh dql dataset list + ``` + Expect (piped → JSON): `movies` (1 collection, 49 queries), `retail` (7, + 72), `retail-joins` (8, 96), `pos` (3, 44). + +- [ ] **Inspect the retail suite** + ```bash + dittosh dql dataset show retail + ``` + Expect: 7 collections (stores, categories, products, customers, inventory, + orders, order_items), scaling dimension `orders`, default 5,000. + +- [ ] **Load it** + ```bash + dittosh dql dataset load retail + ``` + Expect: progress on **stderr**, a clean summary table; exits 0. + +- [ ] **Sanity counts** + ```bash + dittosh dql "SELECT count(*) AS n FROM orders" + dittosh dql "SELECT count(*) AS n FROM customers" + ``` + Expect (piped → JSON): `[{"n":5000}]`, then `[{"n":1251}]`. + +## 2. Housekeeping commands + +- [ ] **Doctor** + ```bash + dittosh dql doctor + ``` + Expect: six `✓` lines — platform, node, data directory, token, sdk, lock. + Exit 0. + +- [ ] **Version** + ```bash + dittosh version + dittosh version --format json | jq '.ditto_sdk' + ``` + Expect: aligned key/value list (version, SDK, channel, token expiry, + paths); the JSON form parses and reports `"5.1.0"`. + +- [ ] **Collections** + ```bash + dittosh dql collections + ``` + Expect: the 7 retail collections plus the `__feature_flags` system + collection. + +- [ ] **Indexes (none yet)** + ```bash + dittosh dql indexes + ``` + Expect: `[]` — `dataset load` never creates indexes. (If you already ran + the demo flow in section 13, you'll see the `adv_*` indexes instead.) + +- [ ] **Update check** (optional — hits the network) + ```bash + dittosh update --check + ``` + Expect: "Already up to date" or "Update available: …" with upgrade + instructions on stderr. + +- [ ] **Skills list** (optional, read-only) + ```bash + dittosh skills list + ``` + Expect: a table of AI agents and whether the DQL skill is installed. + (`skills add` writes into agent config dirs — deliberately not exercised + here.) + +- [ ] **Global flags: colors off** + ```bash + dittosh dql "SELECT * FROM categories" --no-color + ``` + Expect: the table renders with box-drawing characters but zero ANSI color + escapes (compare against a colored run on a TTY). + +## 3. Statement input modes + +- [ ] **Positional statement** — every command above uses it. + +- [ ] **`-e/--execute` form** + ```bash + dittosh dql -e "SELECT count(*) AS n FROM stores" + ``` + Expect: `[{"n":8}]`. + +- [ ] **Batch from a file** + ```bash + printf "SELECT count(*) AS n FROM stores;\nSELECT count(*) AS n FROM categories;\n" > /tmp/mt-batch.sql + dittosh dql -f /tmp/mt-batch.sql + ``` + Expect: two JSON arrays (`8` then `9`), summary `2 ok, 0 failed (of 2)` on + stderr. + +- [ ] **Batch from stdin** + ```bash + printf "SELECT count(*) AS n FROM stores;\nSELECT count(*) AS n FROM categories;\n" | dittosh dql + ``` + Expect: same as `-f`. + +- [ ] **`--continue-on-error` runs past a failure** + ```bash + printf "SELECT count(*) AS n FROM stores;\nSELEC broken;\nSELECT count(*) AS n FROM categories;\n" > /tmp/mt-batch-err.sql + dittosh dql -f /tmp/mt-batch-err.sql --continue-on-error ; echo "exit: $?" + ``` + Expect: the two good results on stdout, a `Query error [query/invalid]` + for `SELEC broken` on stderr, summary `2 ok, 1 failed (of 3)`, `exit: 1`. + Re-run *without* `--continue-on-error`: only the first result, then the + error — the third statement never runs. + +- [ ] **REPL dot-commands are stripped from batches** + ```bash + printf ".collections\nSELECT count(*) AS n FROM stores;\n" | dittosh dql + ``` + Expect: stderr note `skipping REPL command in batch input: .collections`, + then the normal result — dot-commands are REPL-only, never executed. + +- [ ] **`-o` rejected for multi-statement batches** + ```bash + dittosh dql -f /tmp/mt-batch.sql -o /tmp/mt-out.json ; echo "exit: $?" + ``` + Expect: `--out is only supported for a single statement…`, `exit: 2`. + +- [ ] **`-p/--param` binding** + ```bash + dittosh dql "SELECT store_name FROM stores WHERE location.city = :city" -p city=Bellevue + ``` + Expect: the `Zava Retail Bellevue` row. + +- [ ] **`--args` inline JSON** + ```bash + dittosh dql "SELECT store_name FROM stores WHERE location.city = :city" --args '{"city":"Tacoma"}' + ``` + Expect: the `Zava Retail Tacoma` row. + +- [ ] **Multiple statements in argv are refused** + ```bash + dittosh dql "SELECT * FROM stores; SELECT * FROM categories" ; echo "exit: $?" + ``` + Expect: `trailing text after the statement is not executable: "SELECT * + FROM categories" — use -f for multiple statements`, `exit: 2`. + +## 4. Table display on a TTY + +- [ ] **Wide rows fit the window** + ```bash + dittosh dql "SELECT * FROM orders LIMIT 5" + ``` + Expect: the table is exactly your terminal width — no wrapping mush. + Long values (emails, UUIDs) end with `…`. Numbers (`item_count`, + `subtotal`, `total`) right-align. `5 rows` footer. + +- [ ] **Nested/composite values stay readable** + ```bash + dittosh dql "SELECT * FROM inventory LIMIT 5" + ``` + Expect: composite `_id` and `location` render as ellipsized JSON; the + table still fits the window. + +- [ ] **Narrow query gets generous columns** + ```bash + dittosh dql "SELECT store_name, location.city, location.state FROM stores" + ``` + Expect: 8 rows, columns use available width, nothing truncated + unnecessarily. + +- [ ] **Resize resilience** — re-run the first query with a very narrow + window (~60 cols) and a very wide one. Expect: still fits, headers + ellipsize (`fullp…`-style) rather than breaking layout. + +## 5. Pager + +- [ ] **Long results page** + ```bash + dittosh dql "SELECT * FROM orders" + ``` + Expect: opens in `less` (5,000 rows). Arrows/`space` scroll, `/` searches, + `q` quits back to your shell. + +- [ ] **Opt-out flag** + ```bash + dittosh dql "SELECT * FROM orders" --no-pager | wc -l + ``` + Expect: `75002`. No pager, one clean dump. Note: **piped stdout is JSON, + not the table** (that's what makes `| jq` work) — pretty-printed JSON is + 15 lines per order, so 5,000 orders + 2 bracket lines = 75,002. To *see* + the dump instead of counting it: + ```bash + dittosh dql "SELECT * FROM orders" --no-pager --format table | head -12 + ``` + +- [ ] **Opt-out env var** + ```bash + DITTOSH_NO_PAGER=1 dittosh dql "SELECT * FROM orders" | wc -l + ``` + Expect: same — `75002`, no pager. + +- [ ] **Short results never page** + ```bash + dittosh dql "SELECT * FROM categories" + ``` + Expect: prints inline (9 rows), no pager flash. + +## 6. Output formats & export + +- [ ] **Vertical mode: wide rows as record blocks** + ```bash + dittosh dql "SELECT * FROM orders LIMIT 3" --format vertical + ``` + Expect: `── row 1 ──` blocks, `field │ value` lines, values **not** + truncated (full emails/UUIDs visible). + +- [ ] **Markdown to stdout** + ```bash + dittosh dql "SELECT store_name, location.city, is_online FROM stores" --format markdown + ``` + Expect: a GFM table (`| --- |` separator) — paste it into any markdown + doc/GitHub comment and check it renders. + +- [ ] **Markdown to file (extension inference)** + ```bash + dittosh dql "SELECT * FROM products WHERE base_price > 500" -o /tmp/expensive.md + ``` + Expect: `Wrote 12 rows … (markdown)`; the file is a valid markdown table. + +- [ ] **HTML report** + ```bash + dittosh dql "SELECT * FROM orders WHERE store_name = 'Zava Retail Seattle' LIMIT 25" -o /tmp/seattle-orders.html + open /tmp/seattle-orders.html + ``` + Expect: styled table (zebra rows, sticky header), row-count footer, + no external assets. Try your browser's dark mode too. + +- [ ] **JSON and CSV to file** + ```bash + dittosh dql "SELECT * FROM categories" -o /tmp/cats.json + dittosh dql "SELECT * FROM categories" -o /tmp/cats.csv + ``` + Expect: valid JSON array / RFC-4180 CSV with header row. + +- [ ] **Files keep full fidelity** (no `…` truncation) + ```bash + dittosh dql "SELECT * FROM orders LIMIT 5" -o /tmp/orders.txt + ``` + Expect: a plain table with complete values — ellipsization is TTY-only. + +- [ ] **Explicit format beats extension** + ```bash + dittosh dql "SELECT * FROM categories" -o /tmp/cats.txt --format csv + ``` + Expect: CSV content in a `.txt` file. + +- [ ] **`-o` rejected for mutations/DDL** + ```bash + dittosh dql "INSERT INTO stores DOCUMENTS ({'_id':'x'})" -o /tmp/nope.json ; echo "exit: $?" + ``` + Expect: `-o/--out only applies to row-producing statements + (SELECT/EXPLAIN/PROFILE)`, `exit: 2`, nothing written. + +## 7. Safety rails + +- [ ] **No-LIMIT heads-up** (once per config dir, TTY stderr only) + ```bash + DITTOSH_CONFIG_DIR=/tmp/mt-fresh-config dittosh dql "SELECT * FROM stores" + ``` + Expect, before the table: `heads up: this SELECT has no LIMIT — unbounded + queries can return very large result sets. Add LIMIT, use --max-rows, or + write to a file with -o. (shown once)`. Re-run: no warning (the state is + remembered; the fresh `DITTOSH_CONFIG_DIR` is what resets it here). + +- [ ] **`--max-rows` truncates with a warning** + ```bash + dittosh dql "SELECT * FROM categories" --max-rows 3 + ``` + Expect: 3 rows, and on stderr: `showing first 3 of 9 rows — add a LIMIT + clause`. + +## 8. Import external data (`dql import`) + +The standard import format is a **JSON array of objects**; NDJSON (one +object per line) is also accepted. `_id` is optional — docs without one get +a generated UUID. Imports upsert (`ON ID CONFLICT DO UPDATE`), so files +*with* `_id`s re-import cleanly; files *without* duplicate on re-import. + +- [ ] **Import a JSON array** + ```bash + cat > /tmp/import.json <<'EOF' + [ + { "_id": "imp_1", "name": "Brass Hammer", "price": 24.99, "tags": ["hand", "clearance"] }, + { "_id": "imp_2", "name": "Cordless Drill", "price": 129.0 } + ] + EOF + dittosh dql import /tmp/import.json imported_products + ``` + Expect: `Imported 2 documents into imported_products (…s)` on stdout, + progress notes on stderr. + +- [ ] **Query it back** + ```bash + dittosh dql "SELECT * FROM imported_products ORDER BY _id" + ``` + Expect: the 2 docs, nested `tags` array intact. + +- [ ] **Re-import is idempotent** + ```bash + dittosh dql import /tmp/import.json imported_products + dittosh dql "SELECT count(*) AS n FROM imported_products" + ``` + Expect: still `[{"n":2}]` — upsert, not duplicates. + +- [ ] **`--batch-size` is accepted** + ```bash + dittosh dql import /tmp/import.json imported_products --batch-size 1 + ``` + Expect: `Imported 2 documents …` (one doc per INSERT batch). + +- [ ] **Docs without `_id` get a generated UUID** + ```bash + echo '[{"name": "no id here"}]' > /tmp/noid.json + dittosh dql import /tmp/noid.json imported_misc + dittosh dql "SELECT _id, name FROM imported_misc" + ``` + Expect: the doc carries a UUID `_id`. (Import the same file again and you + get a second copy — stable identity needs `_id` in the file.) + +- [ ] **NDJSON works too** + ```bash + printf '{"_id":"n1","v":1}\n{"_id":"n2","v":2}\n' > /tmp/import.ndjson + dittosh dql import /tmp/import.ndjson imported_nd + ``` + Expect: `Imported 2 documents into imported_nd`. + +- [ ] **Bad inputs exit 2, nothing written** + ```bash + dittosh dql import /tmp/missing.json things ; echo "exit: $?" + echo 'not json' > /tmp/bad.json && dittosh dql import /tmp/bad.json things ; echo "exit: $?" + dittosh dql import /tmp/import.json "bad;name" ; echo "exit: $?" + ``` + Expect: clear messages ("Cannot read file", "expected a JSON array…", + "invalid collection name"), all `exit: 2`. + +## 9. jq pipelines (`--args -` / `--args @file`) + +- [ ] **Query → jq** + ```bash + dittosh dql "SELECT _id, product_name, base_price FROM products WHERE base_price > 500" | jq '.[0]' + ``` + Expect: jq parses cleanly (stdout is pure JSON — no banners/warnings). + +- [ ] **Full round trip: query → jq → query** + ```bash + dittosh dql "SELECT _id FROM products WHERE base_price > 500 LIMIT 1" \ + | jq '{pid: .[0]._id}' \ + | dittosh dql "SELECT product_name, base_price FROM products WHERE _id = :pid" --args - + ``` + Expect: one product row, exit 0. + +- [ ] **Second round trip, different shape** + ```bash + dittosh dql "SELECT _id FROM stores WHERE is_online = true" \ + | jq '{sid: .[0]._id}' \ + | dittosh dql "SELECT * FROM inventory WHERE store_id = :sid LIMIT 5" --args - + ``` + Expect: up to 5 inventory rows for the online store. + +- [ ] **Params from a file** + ```bash + echo '{"city": "Bellevue"}' > /tmp/params.json + dittosh dql "SELECT store_name, location.city FROM stores WHERE location.city = :city" --args @/tmp/params.json + ``` + Expect: the Bellevue store row. + +- [ ] **`-p` overrides `--args`** + ```bash + dittosh dql "SELECT store_name FROM stores WHERE location.city = :city" --args @/tmp/params.json -p city=Tacoma + ``` + Expect: the Tacoma store, not Bellevue. + +- [ ] **Bad pipeline input fails clean** + ```bash + echo '[1,2]' | dittosh dql "SELECT * FROM stores" --args - ; echo "exit: $?" + ``` + Expect: `--args must be a JSON object` on stderr, `exit: 2`. + +## 10. REPL (interactive shell) + +- [ ] **Start it** + ```bash + dittosh dql + ``` + Expect: `dql>` prompt. + +- [ ] **In the shell:** + ```sql + SELECT * FROM categories; + SELECT * FROM orders LIMIT 3; + .collections + .indexes orders + .help + ``` + Expect: fitted tables (same TTY rules as one-shot), a dim `(N ms)` timing + note after every statement (always on in the shell — no flag needed), + collection/index listings, help text. Long in-shell results page through + less. + +- [ ] **Multi-line statement** + ```sql + SELECT store_name, location.city + FROM stores + WHERE is_online = true; + ``` + Expect: continuation prompt until the `;`, then the result. + +- [ ] **Discard a half-typed statement** — start a multi-line statement, + then `.break` (or `.clear`) at the continuation prompt. Expect: buffer + discarded, fresh `dql>` prompt, nothing executed. + +- [ ] **Exit** with `.exit` — expect a clean return to your shell. + +## 11. Diagnostics (`--time` / `--explain` / `--profile` / `--advise`) + +- [ ] **Timing footer** + ```bash + dittosh dql "SELECT * FROM orders WHERE store_id = 'store_seattle'" --time --no-pager + ``` + Expect: the table (or JSON when piped), then a dim `Time: N ms` footer on + **stderr**. + +- [ ] **stdout stays clean (jq composability)** + ```bash + dittosh dql "SELECT count(*) AS n FROM orders" --time | jq '.[0].n' + ``` + Expect: `5000` — the timing goes to stderr, the pipe sees pure JSON. + +- [ ] **Timing on a mutation** + ```bash + dittosh dql "INSERT INTO imported_misc DOCUMENTS ({'_id':'t1','v':1}) ON ID CONFLICT DO UPDATE" --time + ``` + Expect: `OK`, then `Time: N ms` on stderr. + +- [ ] **Server breakdown with --profile** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --time --profile + ``` + Expect: the footer gains the server-side timings — + `Time: N ms — server: elapsed … · parse … · plan …`. + +- [ ] **Per-statement timing in a batch** + ```bash + printf "SELECT count(*) FROM orders;\nSELECT count(*) FROM customers;\n" | dittosh dql --time + ``` + Expect: one `Time: N ms` footer per statement, then the + `2 ok, 0 failed (of 2)` summary — all on stderr. + +- [ ] **REPL contrast** — covered in section 10: the shell shows `(N ms)` + per statement with no flag; `--time` is a one-shot/batch flag. + +- [ ] **Plan** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --explain + ``` + Expect: the EXPLAIN operator tree after the result (`Query plan` → + `sequence` → `scan` → `filter` → `finalProjection` on a fresh store; + piped, the plan lands on stderr). + +- [ ] **Profile** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --profile + ``` + Expect: per-operator timings with the hotspot flagged (`▲ HOT`), a + `Results 1` summary line. + +- [ ] **Index advice** — quick check (the full story is the demo flow, + section 13): + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --advise + ``` + Expect: a suggested `CREATE INDEX` on `email`, plus a copy-pasteable + `apply with:` line carrying the full statement. + +## 12. Exit codes & locking + +- [ ] **Query error → 1** + ```bash + dittosh dql "SELEC broken" ; echo "exit: $?" + ``` + +- [ ] **Usage error → 2** + ```bash + dittosh dql "SELECT * FROM stores" --format yaml ; echo "exit: $?" + ``` + +- [ ] **Success → 0** + ```bash + dittosh dql "SELECT * FROM stores LIMIT 1" > /dev/null ; echo "exit: $?" + ``` + +- [ ] **Lock → 4** (optional, two terminals): start the REPL in one + terminal (`dittosh dql`), then in another: + ```bash + dittosh dql "SELECT * FROM stores LIMIT 1" ; echo "exit: $?" + ``` + Expect: a "in use by another dittosh process" message, `exit: 4`. + `.exit` the REPL and re-run — succeeds. + +## 13. Demo flow: fresh store → indexes → ADVISE & EXPLAIN + +A narrated walkthrough you can run as a demo: nuke the store, reload retail, +prove indexes are absent, then let ADVISE + EXPLAIN tell the index story. +(Uses the default data dir throughout.) + +- [ ] **13.1 Delete the database** + ```bash + dittosh dql delete-store # refuses without -y: exit 2 + dittosh dql delete-store -y + ``` + Expect: `Deleted the store at …` — the directory is gone from disk: + collections, indexes, lock file, everything. (`dataset reset retail -y` + is the lighter option — documents evicted, indexes kept.) + +- [ ] **13.2 Delete is lock-aware** (optional, two terminals): start the + REPL in one (`dittosh dql`), run `dittosh dql delete-store -y` in the + other. Expect: exit 4, "in use by another dittosh process", store + intact. `.exit` the REPL and re-run — deletion succeeds. + +- [ ] **13.3 Load retail fresh** + ```bash + dittosh dql dataset load retail + dittosh dql "SELECT count(*) AS n FROM orders" + ``` + Expect: `[{"n":5000}]`. + +- [ ] **13.4 Prove a fresh load has no indexes** + ```bash + dittosh dql "SELECT * FROM system:indexes" + ``` + Expect: `[]` — `dataset load` never creates indexes (the benchmark + catalog pairs `_no_index`/`_indexed` variants; indexes are per-query + setup). This is what gives ADVISE something to say. + +- [ ] **13.5 Baseline: the unindexed query** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --explain + ``` + Expect: the plan leads with `scan collection=customers` (full collection + scan) followed by a separate `filter` operator. With `--profile` instead: + `scan … · 1251 out` — it reads **every** customer doc to find one, and + `filter` is flagged ▲ HOT. + +- [ ] **13.6 ADVISE recommends the index** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --advise + ``` + Expect: `customers — equality predicates on email` with + ``CREATE INDEX IF NOT EXISTS adv_customers_email ON default:`customers` (`email` ASC)``, + plus an `apply with:` line carrying the full statement — copy-pasteable + verbatim (`dittosh dql --advise --apply "SELECT * FROM customers WHERE …"`). + +- [ ] **13.7 Apply it** + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --advise --apply -y + ``` + Expect: the same advice with `✓ created`. + +- [ ] **13.8 Validate the index works** + ```bash + dittosh dql indexes customers + ``` + Expect: `customers.adv_customers_email` on `email` (asc). + + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --explain + ``` + Expect: the plan now leads with `indexScan … "index":"adv_customers_email"` + + `fetch` — no more full `scan`. + + ```bash + dittosh dql "SELECT * FROM customers WHERE email = 'john21@example.net'" --profile + ``` + Expect: `indexScan … · 1 out` — exactly one document read, versus 1,251 + before. That's the demo money shot. + +- [ ] **13.9 Catalog cross-check** (optional): the benchmark suite ships its + own indexed variant of this query: + ```bash + dittosh dql dataset run customers__select__by_email_indexed --dataset retail --setup + ``` + Expect: `--setup` runs the catalog's `CREATE INDEX` DDL first, then the + query returns the anchor customer. + +## 14. Cleanup + +- [ ] **Delete the store when done** + ```bash + dittosh dql delete-store -y + ``` From c81d3d2543ed220ecad0cffebdea72b76a4f8118 Mon Sep 17 00:00:00 2001 From: Aaron LaBeau Date: Wed, 2 Sep 2026 21:39:24 -0500 Subject: [PATCH 2/4] Enhancements: new renderers, pager, import, delete-store, diagnostics flags - renderers: html, markdown, vertical, columns; terminal-width table fitting - pager (less/less, --no-pager, DITTOSH_NO_PAGER) - dql import (JSON array / NDJSON, upsert batches) and dql delete-store (-y, lock-aware) - dql exec flags: --time, --explain, --profile, --advise/--apply, -o/--out - advise: copy-pasteable apply-with line (interpolates the analyzed statement, shell-quoted) - params: -p/--param and --args (inline/-/@file) - release install script, docs, and unit/integration/e2e coverage All manually tested (see manualTesting.*.md). --- .gitignore | 1 + AGENTS.md | 7 +- README.md | 42 ++++++- scripts/install-release.sh | 17 +++ src/cli/default-command.ts | 11 +- src/cli/groups/dql/delete-store.ts | 95 ++++++++++++++ src/cli/groups/dql/import.ts | 108 ++++++++++++++++ src/cli/groups/dql/index.ts | 127 +++++++++++++++++-- src/cli/groups/dql/run.ts | 14 ++- src/cli/groups/system/index.ts | 5 +- src/cli/version.ts | 16 +++ src/query/params.ts | 29 +++++ src/render/advise.ts | 9 +- src/render/columns.ts | 46 +++++++ src/render/html.ts | 53 ++++++++ src/render/markdown.ts | 23 ++++ src/render/output.ts | 32 ++++- src/render/pager.ts | 42 +++++++ src/render/table.ts | 130 +++++++++++++------ src/render/vertical.ts | 26 ++++ tests/e2e/delete-store.test.ts | 83 +++++++++++++ tests/e2e/import.test.ts | 180 +++++++++++++++++++++++++++ tests/e2e/modes.test.ts | 193 +++++++++++++++++++++++++++++ tests/e2e/system.test.ts | 2 + tests/integration/session.test.ts | 18 +++ tests/unit/advise.test.ts | 22 ++++ tests/unit/cli-system.test.ts | 2 + tests/unit/default-command.test.ts | 10 +- tests/unit/delete-store.test.ts | 128 +++++++++++++++++++ tests/unit/html.test.ts | 43 +++++++ tests/unit/import.test.ts | 130 +++++++++++++++++++ tests/unit/markdown.test.ts | 42 +++++++ tests/unit/output.test.ts | 21 ++++ tests/unit/pager.test.ts | 84 +++++++++++++ tests/unit/params.test.ts | 55 +++++++- tests/unit/run.test.ts | 40 ++++++ tests/unit/table.test.ts | 68 ++++++++++ tests/unit/vertical.test.ts | 40 ++++++ tsup.config.ts | 6 + 39 files changed, 1938 insertions(+), 62 deletions(-) create mode 100755 scripts/install-release.sh create mode 100644 src/cli/groups/dql/delete-store.ts create mode 100644 src/cli/groups/dql/import.ts create mode 100644 src/render/columns.ts create mode 100644 src/render/html.ts create mode 100644 src/render/markdown.ts create mode 100644 src/render/pager.ts create mode 100644 src/render/vertical.ts create mode 100644 tests/e2e/delete-store.test.ts create mode 100644 tests/e2e/import.test.ts create mode 100644 tests/unit/delete-store.test.ts create mode 100644 tests/unit/html.test.ts create mode 100644 tests/unit/import.test.ts create mode 100644 tests/unit/markdown.test.ts create mode 100644 tests/unit/pager.test.ts create mode 100644 tests/unit/vertical.test.ts diff --git a/.gitignore b/.gitignore index 6d8311a..acfa5ce 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ coverage/ *.tgz .env .DS_Store +tmp/ diff --git a/AGENTS.md b/AGENTS.md index 8af580e..5da4b58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ The Ditto CLI: an npm/Homebrew-installable TypeScript CLI (binary `dittosh` — - `src/identity/` — token loading (dev env / release reassembly), expiry - `src/ditto/session.ts` — the only SDK touchpoint: init/open/close, log taming, lock mapping - `src/query/` — statement classifier, splitter, param binding, result extraction, row cap -- `src/render/` — table/JSON/CSV, `-o/--out`, (M4: explain/profile/advise renderers) +- `src/render/` — table (terminal-width fitting)/JSON/CSV/markdown/HTML/vertical, pager (`$PAGER`/`less`, `--no-pager`/`DITTOSH_NO_PAGER`), `-o/--out`, (M4: explain/profile/advise renderers) - `datasets/` — vendored benchmark suite definitions (movies, retail, retail-joins, pos); **no generated data ever committed** - `scripts/` — `spike-a.mjs` (SDK verification), `stamp-token.ts` (M8) - `tests/unit|integration|e2e` + `tests/setup/env.ts` (loads `.env`) + `tests/helpers/` @@ -38,8 +38,13 @@ npm run test:unit|test:int|test:e2e npm run typecheck # tsc --noEmit npm run lint # biome check npm run spike:a # SDK init/token/DQL smoke script +scripts/install-release.sh # stamp token → RELEASE=true build → npm i -g . (installs `dittosh` globally) ``` +## User phrasing worth knowing + +- **"build a new version and install it"** = run `scripts/install-release.sh` — a *release* build (stamped token, env credentials disabled) installed globally on this machine so the user can test `dittosh` directly. Not a dev build, not a version-number bump. + ## Testing conventions - **unit** (`tests/unit`): no SDK. Fast; snapshot-friendly (`FORCE_COLOR=0` in setup). diff --git a/README.md b/README.md index c8965db..03966f4 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,11 @@ dittosh dql # interactive REPL | `-f, --file ` | run statements from a file (`;`-separated) | | `-e, --execute ` | explicit statement (alternative to the positional) | | `-p, --param name=value` | bind `:name` parameters (repeatable; values JSON-parsed with string fallback) | -| `--args ` | bind parameters from a JSON object | -| `-o, --out ` | write results to a file (format from extension or `--format`; uncapped unless `--max-rows` is explicit) | -| `--format table\|json\|csv` | output format (default: table on TTY, JSON when piped) | +| `--args ` | bind parameters from a JSON object — `-` reads stdin, `@file` reads a file | +| `-o, --out ` | write results to a file (format from extension — `.json`/`.csv`/`.md`/`.html` — or `--format`; uncapped unless `--max-rows` is explicit) | +| `--format table\|json\|csv\|markdown\|html\|vertical` | output format (default: table on TTY, JSON when piped). `vertical` = one block per row, values never truncated | | `--max-rows ` | display cap, default 10,000 | +| `--no-pager` | never pipe long TTY output through `$PAGER`/`less` (also: `DITTOSH_NO_PAGER=1`) | | `--continue-on-error` | keep running after a failure (batch mode) | | `--time` | timing footer (host wall-clock + server parse/plan/elapsed when profiling) | | `--explain` | print the query plan (EXPLAIN side-trip, SELECTs only) | @@ -76,6 +77,15 @@ dittosh dql # interactive REPL | `--apply` | apply ADVISE's suggested `CREATE INDEX` statements (prompts; `-y` skips) | | `-y, --yes` | skip confirmation prompts | +On a terminal, tables fit the window width (long values ellipsize with `…`) and long results page through `less`. Piped stdout is always clean JSON, so results compose with `jq` — and `--args -` feeds a transformed result back in as parameters: + +```bash +# find an id with one query, fetch the full doc with another +dittosh dql "SELECT _id FROM movies WHERE _id.year = '2001' LIMIT 1" \ + | jq '{id: .[0]._id}' \ + | dittosh dql "SELECT * FROM movies WHERE _id = :id" --args - +``` + ### `dittosh dql doctor` Platform/arch, Node version, data-directory writability, token validity + expiry, SDK load, and store-lock probe — with an exit code that says what's wrong. @@ -84,6 +94,32 @@ Platform/arch, Node version, data-directory writability, token validity + expiry List collections (`system:collections`) and indexes (`system:indexes`). +### `dittosh dql delete-store` + +Permanently delete the local store — the whole data directory: all collections, indexes, and files. Requires `-y` (no prompt); refuses while another process holds the store open (exit 4), and refuses absurd targets like `$HOME` or the cwd. To clear just one dataset's documents instead, use `dittosh dql dataset reset -y`. + +### `dittosh dql import ` + +Import your own data. The standard format is a **JSON array of objects**: + +```json +[ + { "_id": "prod_1", "name": "Brass Hammer", "price": 24.99 }, + { "_id": "prod_2", "name": "Cordless Drill", "price": 129.0 } +] +``` + +```bash +dittosh dql import products.json products +dittosh dql "SELECT * FROM products WHERE price > 100" +``` + +- **NDJSON** (one object per line) is accepted too — detected automatically from the first character (`[` → array, `{` → NDJSON). +- **`_id` is optional.** Documents without one get a generated UUID. Imports upsert (`ON ID CONFLICT DO UPDATE`), so re-importing a file with `_id`s is idempotent; re-importing docs *without* `_id` duplicates them. +- **Collection names** must be identifier-style: letters, digits, underscores, not starting with a digit. +- Large files insert in batches (`--batch-size`, default 500); progress on stderr, summary on stdout. +- Exit codes: `2` unreadable/invalid file or bad collection name, `1` insert failed, `0` ok. + ### `dittosh dql dataset` — sample data Four built-in datasets vendored from Ditto's benchmark suites — movies, retail, retail-joins, pos — generated on the fly (nothing pre-generated ships in the package): diff --git a/scripts/install-release.sh b/scripts/install-release.sh new file mode 100755 index 0000000..1d1d605 --- /dev/null +++ b/scripts/install-release.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Build a release bundle and install it globally as `dittosh` for local testing. +# +# scripts/install-release.sh +# +# Steps: stamp the obfuscated token from .env → RELEASE=true build → npm i -g . +# Release builds ignore .env credentials, so the installed binary runs anywhere. +set -euo pipefail +cd "$(dirname "$0")/.." # repo root + +npm run stamp:token # build/token-chunks.ts from .env (gitignored) +RELEASE=true npm run build # release bundle → dist/cli.js +npm install -g . # global @dittolive/cli → `dittosh` on PATH + +echo +echo "Installed: $(command -v dittosh)" +dittosh version diff --git a/src/cli/default-command.ts b/src/cli/default-command.ts index 156870a..3f1c9ec 100644 --- a/src/cli/default-command.ts +++ b/src/cli/default-command.ts @@ -27,7 +27,16 @@ const DEFAULT_SUBCOMMANDS: Record }> = { dql: { exec: "exec", - known: new Set(["exec", "doctor", "collections", "indexes", "dataset", "help"]), + known: new Set([ + "exec", + "doctor", + "collections", + "indexes", + "dataset", + "delete-store", + "import", + "help", + ]), }, }; diff --git a/src/cli/groups/dql/delete-store.ts b/src/cli/groups/dql/delete-store.ts new file mode 100644 index 0000000..c5c48bc --- /dev/null +++ b/src/cli/groups/dql/delete-store.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { isBogusDataDir, resolveDataDir } from "../../../config/paths.js"; +import { loadIdentity } from "../../../identity/token.js"; + +export interface DeleteStoreResult { + /** 0 deleted/nothing to delete · 2 usage · 3 delete failed · 4 store locked. */ + code: 0 | 2 | 3 | 4; + message: string; +} + +export interface DeleteStoreOptions { + dataDir?: string; + yes?: boolean; + /** Injectable for tests (DITTOSH_DATA_DIR). */ + env?: NodeJS.ProcessEnv; + /** + * Injectable for tests: open+close the store to prove it's not locked. + * Default opens a real session. Only runs when a `__ditto_lock_file` + * exists (a dir without one was never opened by Ditto). + */ + probeLock?: (dir: string) => Promise; + /** Injectable for tests. Default fs.rmSync(recursive, force). */ + rm?: (dir: string) => void; +} + +/** + * Permanently delete the local store — the whole data directory, indexes and + * lock files included (unlike `dataset reset`, which only EVICTs documents). + * Never gated on token validity: deleting files must always be possible. + */ +export async function deleteStore(opts: DeleteStoreOptions = {}): Promise { + const env = opts.env ?? process.env; + + // Mirror doctor/openSession: the flag wins when present; only flag-absent + // env is bogus-checked. + if (isBogusDataDir(opts.dataDir)) { + return { code: 2, message: "-d/--data-dir requires a directory path" }; + } + if (!opts.dataDir?.trim() && isBogusDataDir(env.DITTOSH_DATA_DIR)) { + return { code: 2, message: "DITTOSH_DATA_DIR requires a directory path" }; + } + const dir = resolveDataDir(opts.dataDir, env); + + // Deleting recursively is irreversible — refuse targets that are clearly + // not a store, no matter how they were passed. + if (dir === path.parse(dir).root || dir === os.homedir() || dir === process.cwd()) { + return { + code: 2, + message: `Refusing to delete ${dir} — that's not a dittosh data directory.`, + }; + } + + if (!fs.existsSync(dir)) { + return { code: 0, message: `No store at ${dir} — nothing to delete.` }; + } + + if (!opts.yes) { + return { + code: 2, + message: `This permanently deletes the store at ${dir} — all collections, indexes, and files. Re-run with --yes to confirm.`, + }; + } + + // Lock probe: never delete a store another process has open. Probe failures + // that aren't locks (expired token, SDK unavailable) don't block deletion. + if (fs.existsSync(path.join(dir, "__ditto_lock_file"))) { + const probe = + opts.probeLock ?? + (async (d: string) => { + const { DittoSession } = await import("../../../ditto/session.js"); + const session = await DittoSession.open(loadIdentity(env), d); + await session.close(); + }); + try { + await probe(dir); + } catch (err) { + if (err instanceof Error && err.name === "LockError") { + return { code: 4, message: err.message }; + } + } + } + + const rm = opts.rm ?? ((d: string) => fs.rmSync(d, { recursive: true, force: true })); + try { + rm(dir); + } catch (err) { + return { + code: 3, + message: `Cannot delete ${dir}: ${(err as NodeJS.ErrnoException).message}`, + }; + } + return { code: 0, message: `Deleted the store at ${dir}.` }; +} diff --git a/src/cli/groups/dql/import.ts b/src/cli/groups/dql/import.ts new file mode 100644 index 0000000..e17530c --- /dev/null +++ b/src/cli/groups/dql/import.ts @@ -0,0 +1,108 @@ +import crypto from "node:crypto"; +import type { QueryExecutor } from "../../../ditto/session.js"; + +/** Import usage/input failures → exit 2. */ +export class ImportError extends Error { + readonly exitCode = 2; + constructor(message: string) { + super(message); + this.name = "ImportError"; + } +} + +/** Collection names become DQL identifiers in the INSERT — validate strictly (no injection surface). */ +export function isValidCollectionName(name: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name); +} + +function validateDocs(docs: unknown[], file: string): Record[] { + for (let i = 0; i < docs.length; i++) { + const d = docs[i]; + if (typeof d !== "object" || d === null || Array.isArray(d)) { + throw new ImportError(`${file}: document #${i + 1} is not a JSON object`); + } + } + return docs as Record[]; +} + +/** + * Parse the standard import format: a JSON array of objects (`[{...}, ...]`). + * NDJSON (one object per line) is also accepted — detected by the first + * non-whitespace character (`[` → array, `{` → NDJSON). + */ +export function parseImportFile(text: string, file = "input"): Record[] { + const trimmed = text.trimStart(); + if (trimmed === "") { + throw new ImportError(`${file} is empty — expected a JSON array of documents`); + } + if (trimmed.startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new ImportError(`${file}: invalid JSON — ${(err as Error).message}`); + } + // A leading "[" that parses is always an array. + return validateDocs(parsed as unknown[], file); + } + if (trimmed.startsWith("{")) { + const docs: unknown[] = []; + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + if (!line) continue; + try { + docs.push(JSON.parse(line)); + } catch { + throw new ImportError( + `${file}: line ${i + 1} is not valid JSON (NDJSON = one document per line)`, + ); + } + } + return validateDocs(docs, file); + } + throw new ImportError( + `${file}: expected a JSON array of documents ([...]) or NDJSON (one object per line)`, + ); +} + +export interface ImportOptions { + batchSize?: number; + onProgress?: (inserted: number, total: number) => void; +} + +/** + * Insert documents in batches using the dataset loader's proven pattern: + * `INSERT INTO DOCUMENTS (deserialize_json(:docN)),… ON ID CONFLICT + * DO UPDATE` so re-imports are idempotent. Documents without an `_id` get a + * generated UUID (so re-importing a file without ids duplicates them — docs + * should include `_id` for stable identity). + */ +export async function importDocuments( + session: QueryExecutor, + docs: Record[], + collection: string, + opts: ImportOptions = {}, +): Promise { + const batchSize = opts.batchSize ?? 500; + let inserted = 0; + for (let i = 0; i < docs.length; i += batchSize) { + const chunk = docs + .slice(i, i + batchSize) + .map((d) => (d._id == null ? { _id: crypto.randomUUID(), ...d } : d)); + if (chunk.length === 0) continue; + const args: Record = {}; + const placeholders = chunk.map((doc, j) => { + const key = `doc${j}`; + args[key] = JSON.stringify(doc); + return `(deserialize_json(:${key}))`; + }); + await session.execute( + `INSERT INTO ${collection} DOCUMENTS ${placeholders.join(", ")} ON ID CONFLICT DO UPDATE`, + args, + ); + inserted += chunk.length; + opts.onProgress?.(inserted, docs.length); + } + return inserted; +} diff --git a/src/cli/groups/dql/index.ts b/src/cli/groups/dql/index.ts index b2d5c6e..7ed5167 100644 --- a/src/cli/groups/dql/index.ts +++ b/src/cli/groups/dql/index.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import chalk from "chalk"; import type { Command } from "commander"; -import { isBogusDataDir, resolveDataDir } from "../../../config/paths.js"; +import { expandTilde, isBogusDataDir, resolveDataDir } from "../../../config/paths.js"; import { DataDirError, DittoSession, @@ -11,14 +11,21 @@ import { } from "../../../ditto/session.js"; import { daysUntilExpiry, IdentityError, loadIdentity } from "../../../identity/token.js"; import { classify } from "../../../query/execute.js"; -import { ParamError, parseParams, parsePositiveInt } from "../../../query/params.js"; +import { + ParamError, + parseParams, + parsePositiveInt, + resolveArgsSource, +} from "../../../query/params.js"; import { isBlankOrComments, splitComplete, splitStatements } from "../../../query/split.js"; import { FormatError, resolveFormat } from "../../../render/output.js"; import { runBatch, stripDotCommandLines } from "./batch.js"; import { registerDatasetCommands } from "./dataset.js"; +import { deleteStore } from "./delete-store.js"; import { collectDoctorChecks } from "./doctor.js"; +import { ImportError, importDocuments, isValidCollectionName, parseImportFile } from "./import.js"; import { startRepl } from "./repl.js"; -import { runStatement, validateOutPath } from "./run.js"; +import { note, runStatement, validateOutPath } from "./run.js"; interface ExecOpts { dataDir?: string; @@ -29,6 +36,7 @@ interface ExecOpts { param?: string[]; args?: string; continueOnError?: boolean; + pager?: boolean; time?: boolean; explain?: boolean; profile?: boolean; @@ -100,6 +108,7 @@ function execRunOpts(opts: ExecOpts, maxRowsExplicit: boolean) { maxRowsExplicit, out: opts.out, params: parseParams(opts.param, opts.args), + pager: opts.pager, time: opts.time, explain: opts.explain, profile: opts.profile, @@ -114,6 +123,13 @@ function collectParam(value: string, previous: string[]): string[] { return [...previous, value]; } +/** Fully consume stdin as text (statement batches, `--args -`). */ +async function readStdinText(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + /** Run statements from a file or piped stdin (validation already done by the caller). */ /** Run a batch of statements (validation + dot-command stripping already done by the caller). */ async function batchFromText( @@ -195,6 +211,79 @@ export function registerDqlGroup(dql: ReturnType): void { } }); + dql + .command("delete-store") + .description("Permanently delete the local store — all collections, indexes, and files") + .option("-y, --yes", "confirm without prompting", false) + .option("-d, --data-dir ", "override the data directory") + .action(async (opts: { yes: boolean; dataDir?: string }) => { + const r = await deleteStore(opts); + if (r.code === 0) console.log(r.message); + else console.error(chalk.red(r.message)); + process.exitCode = r.code; + }); + + dql + .command("import") + .description("Import documents from a JSON file into a collection") + .argument("", "JSON file: an array of objects, or NDJSON (one object per line)") + .argument("", "target collection (created on first insert)") + .option("-d, --data-dir ", "override the data directory") + .option("--batch-size ", "documents per INSERT batch", "500") + .action( + async (file: string, collection: string, opts: { dataDir?: string; batchSize: string }) => { + // Validate everything before opening the store (usage beats lock). + let docs: Record[]; + let batchSize: number; + try { + if (!isValidCollectionName(collection)) { + throw new ImportError( + `invalid collection name "${collection}" — letters, digits, and underscores only (must not start with a digit)`, + ); + } + let text: string; + try { + text = fs.readFileSync(expandTilde(file), "utf8"); + } catch (err) { + throw new ImportError(`Cannot read file: ${file} (${(err as Error).message})`); + } + docs = parseImportFile(text, file); + batchSize = parsePositiveInt(opts.batchSize, "--batch-size", 500); + } catch (err) { + if (err instanceof ImportError || err instanceof ParamError) { + console.error(chalk.red(err.message)); + process.exitCode = err.exitCode; + return; + } + throw err; + } + + const session = await openSession(opts); + if (!session) return; + try { + const started = performance.now(); + note(`Importing ${docs.length.toLocaleString()} documents into ${collection}…`); + const inserted = await importDocuments(session, docs, collection, { + batchSize, + onProgress: (ins, total) => + note(` ${collection}: ${ins.toLocaleString()}/${total.toLocaleString()}`), + }); + const elapsed = ((performance.now() - started) / 1000).toFixed(1); + console.log( + `Imported ${inserted.toLocaleString()} document${inserted === 1 ? "" : "s"} into ${collection} (${elapsed}s)`, + ); + } catch (err) { + const e = err as { message?: string; code?: string }; + console.error( + chalk.red(`Import failed${e.code ? ` [${e.code}]` : ""}: ${e.message ?? err}`), + ); + process.exitCode = 1; + } finally { + await session.close(); + } + }, + ); + // Execution subcommand (also the default — see rewriteDefaultSubcommand in // the CLI entry, which maps `dittosh dql ` → `dittosh dql exec `; // an action directly on `dql` would swallow same-named child options). @@ -210,11 +299,15 @@ export function registerDqlGroup(dql: ReturnType): void { collectParam, [] as string[], ) - .option("--args ", "bind parameters from a JSON object") + .option( + "--args ", + "bind parameters from a JSON object ('-' reads stdin, '@file' reads a file)", + ) .option("-d, --data-dir ", "override the data directory") .option("-o, --out ", "write results to a file (format from extension or --format)") - .option("--format ", "table | json | csv") + .option("--format ", "table | json | csv | markdown | html | vertical") .option("--max-rows ", "maximum rows to display", "10000") + .option("--no-pager", "never pipe results through $PAGER/less") .option("--continue-on-error", "keep running statements after a failure (-f/stdin)", false) .option("--time", "print timing after the results", false) .option("--explain", "run EXPLAIN on the statement and print the plan", false) @@ -253,7 +346,25 @@ export function registerDqlGroup(dql: ReturnType): void { "--advise renders a report, not rows — it can't be combined with -o/--out", ); } - runOpts = execRunOpts(opts, command.getOptionValueSource("maxRows") === "cli"); + // `--args -` reads the JSON params object from stdin — which requires + // piped stdin AND a statement from argv/-e/-f (else stdin IS the batch). + if (opts.args === "-") { + if (process.stdin.isTTY) { + throw new ParamError( + "--args - reads a JSON object from stdin, but stdin is a terminal", + ); + } + if (!positional && !opts.execute && !opts.file) { + throw new ParamError( + "--args - consumes stdin — pass the statement positionally, via -e, or via -f", + ); + } + } + const argsJson = await resolveArgsSource(opts.args, readStdinText); + runOpts = execRunOpts( + { ...opts, args: argsJson }, + command.getOptionValueSource("maxRows") === "cli", + ); } catch (err) { if (err instanceof ParamError || err instanceof FormatError) { console.error(chalk.red(err.message)); @@ -384,9 +495,7 @@ export function registerDqlGroup(dql: ReturnType): void { batchSource = opts.file; } else if (!statement) { // piped stdin — fully consume before opening (usage beats lock) - const chunks: Buffer[] = []; - for await (const chunk of process.stdin) chunks.push(chunk as Buffer); - batchText = Buffer.concat(chunks).toString("utf8"); + batchText = await readStdinText(); } // Batch usage validation before touching the store. diff --git a/src/cli/groups/dql/run.ts b/src/cli/groups/dql/run.ts index c577562..d61be0e 100644 --- a/src/cli/groups/dql/run.ts +++ b/src/cli/groups/dql/run.ts @@ -13,6 +13,7 @@ import { hasLimitClause } from "../../../query/split.js"; import { renderAdvice } from "../../../render/advise.js"; import { renderExplain } from "../../../render/explain.js"; import { formatForOutFile, renderRows, resolveFormat } from "../../../render/output.js"; +import { type PageOptions, pageIfLong } from "../../../render/pager.js"; import { renderProfile } from "../../../render/profile.js"; export interface RunOptions { @@ -41,6 +42,10 @@ export interface RunOptions { confirm?: (message: string) => Promise; /** Injectable "is stdout a TTY" (diagnostic sections go to stderr when piped). */ stdoutIsTTY?: boolean; + /** --no-pager sets this false; undefined = page long TTY output automatically. */ + pager?: boolean; + /** Injectable pager (defaults to pageIfLong) for tests. */ + page?: (text: string, opts?: PageOptions) => boolean; } export interface RunResult { @@ -252,7 +257,14 @@ export async function runStatement( `Wrote ${rowsForFile.length.toLocaleString()} row${rowsForFile.length === 1 ? "" : "s"} to ${opts.out} in ${elapsedMs.toFixed(0)} ms (${format})${cappedNote}`, ); } else { - console.log(renderRows(shown, format)); + // Tables fit the terminal width on a TTY; pipes/files keep full fidelity. + // (A 0-column terminal is degenerate — some ptys report 0x0 — treat as unknown.) + const tty = opts.stdoutIsTTY ?? process.stdout.isTTY; + const rendered = renderRows(shown, format, { + maxWidth: tty ? process.stdout.columns || undefined : undefined, + }); + const page = opts.page ?? pageIfLong; + if (!page(rendered, { disabled: opts.pager === false })) console.log(rendered); } if (truncated && !opts.out) { console.error( diff --git a/src/cli/groups/system/index.ts b/src/cli/groups/system/index.ts index 238476e..fdba725 100644 --- a/src/cli/groups/system/index.ts +++ b/src/cli/groups/system/index.ts @@ -5,7 +5,7 @@ import { resolveDataDir } from "../../../config/paths.js"; import { daysUntilExpiry, loadIdentity } from "../../../identity/token.js"; import { detectChannel } from "../../../update/channel.js"; import { checkForUpdate, isNewer, readCachedUpdate } from "../../../update/check.js"; -import { CLI_VERSION } from "../../version.js"; +import { CLI_VERSION, DITTO_SDK_VERSION } from "../../version.js"; /** Injectable so tests don't hit the registry or spawn anything. */ export interface SystemDeps { @@ -27,7 +27,7 @@ const realDeps: SystemDeps = { export function registerSystemGroup(program: Command, deps: SystemDeps = realDeps): void { program .command("version") - .description("Show version, install channel, token expiry, and paths") + .description("Show CLI + Ditto SDK versions, install channel, token expiry, and paths") .option("--format ", "text | json") .action(async (opts: { format?: string }) => { if (opts.format !== undefined && opts.format !== "text" && opts.format !== "json") { @@ -60,6 +60,7 @@ export function registerSystemGroup(program: Command, deps: SystemDeps = realDep const info = { version: CLI_VERSION, + ditto_sdk: DITTO_SDK_VERSION, channel: channel.detail, update: updateLine, token_expires: expiry, diff --git a/src/cli/version.ts b/src/cli/version.ts index 5c50cc2..829aabd 100644 --- a/src/cli/version.ts +++ b/src/cli/version.ts @@ -3,6 +3,7 @@ import { createRequire } from "node:module"; // Injected by tsup at build time (see tsup.config.ts). Dev (tsx) falls back to // reading package.json from either src/ (dev) or dist/ (built) locations. declare const __CLI_VERSION__: string | undefined; +declare const __DITTO_SDK_VERSION__: string | undefined; function devVersion(): string { const req = createRequire(import.meta.url); @@ -16,5 +17,20 @@ function devVersion(): string { return "0.0.0-dev"; } +// The SDK has no runtime version export, so read its package.json. The SDK is +// external in the bundle (native .node binaries), so node_modules is always +// present at runtime — this resolves in dev, built, and installed forms alike. +function devSdkVersion(): string { + const req = createRequire(import.meta.url); + try { + return (req("@dittolive/ditto/package.json") as { version: string }).version; + } catch { + return "unknown"; + } +} + export const CLI_VERSION: string = typeof __CLI_VERSION__ !== "undefined" ? __CLI_VERSION__ : devVersion(); + +export const DITTO_SDK_VERSION: string = + typeof __DITTO_SDK_VERSION__ !== "undefined" ? __DITTO_SDK_VERSION__ : devSdkVersion(); diff --git a/src/query/params.ts b/src/query/params.ts index 9f89e9d..588950c 100644 --- a/src/query/params.ts +++ b/src/query/params.ts @@ -1,4 +1,6 @@ +import fs from "node:fs"; import type { DQLQueryArguments } from "@dittolive/ditto"; +import { expandTilde } from "../config/paths.js"; export class ParamError extends Error { readonly exitCode = 2; @@ -8,6 +10,33 @@ export class ParamError extends Error { } } +/** + * Resolve the --args value to a JSON string: + * - `--args '{"id":1}'` inline (returned as-is) + * - `--args -` read from stdin (the jq pipeline form) + * - `--args @file.json` read from a file (curl-style) + * The result is validated by parseParams (must be a JSON object). + */ +export async function resolveArgsSource( + value: string | undefined, + readStdin: () => Promise, +): Promise { + if (value === undefined) return undefined; + if (value === "-") return readStdin(); + if (value.startsWith("@")) { + const file = value.slice(1).trim(); + if (!file) { + throw new ParamError("--args @ requires a file path (e.g. --args @params.json)"); + } + try { + return fs.readFileSync(expandTilde(file), "utf8"); + } catch (err) { + throw new ParamError(`--args: cannot read ${file}: ${(err as Error).message}`); + } + } + return value; +} + /** Parse a CLI integer flag; usage error (exit 2) on garbage or out-of-range. */ export function parsePositiveInt( raw: string | undefined, diff --git a/src/render/advise.ts b/src/render/advise.ts index bfc1e90..c9e1381 100644 --- a/src/render/advise.ts +++ b/src/render/advise.ts @@ -1,6 +1,11 @@ import chalk from "chalk"; import type { QueryAdvice } from "../query/advise.js"; +/** Double-quote a statement for copy-paste into bash/zsh (escape the four "-specials). */ +function shellQuote(s: string): string { + return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`")}"`; +} + /** * Edge Studio's "Index advice" card, rendered for the terminal. * `applied` maps suggestion statement → outcome after --apply. @@ -39,8 +44,10 @@ export function renderAdvice( } if (!applied) { lines.push(""); + // The analyzed statement is known — print the literal command so it's copy-pasteable. + const target = advice.statement ? shellQuote(advice.statement) : '""'; lines.push( - chalk.dim(' apply with: dittosh dql --advise --apply "" (prompts; -y skips)'), + chalk.dim(` apply with: dittosh dql --advise --apply ${target} (prompts; -y skips)`), ); } return lines.join("\n"); diff --git a/src/render/columns.ts b/src/render/columns.ts new file mode 100644 index 0000000..86ccf2a --- /dev/null +++ b/src/render/columns.ts @@ -0,0 +1,46 @@ +/** + * Shared row-shape logic for display renderers (table/markdown/html/vertical): + * `_id` column first, then the union of all other keys in first-seen order. + * CSV keeps its own copy deliberately (data interchange: no attachment + * placeholders, no display-oriented cell text). + */ +export function collectColumns(rows: Record[]): string[] { + const cols: string[] = []; + const seen = new Set(); + if (rows.some((r) => "_id" in r)) { + cols.push("_id"); + seen.add("_id"); + } + for (const row of rows) { + for (const key of Object.keys(row)) { + if (!seen.has(key)) { + seen.add(key); + cols.push(key); + } + } + } + return cols; +} + +/** + * Raw display text for a cell value (no escaping/sanitizing — each renderer + * applies its own). null → "null", undefined → "", nested values → compact + * JSON, attachment handles → a placeholder. + */ +export function cellText(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return ""; + if (typeof value === "object") { + const v = value as Record; + // Ditto attachment handles surface as objects with an id + len. + if ( + typeof v.id === "string" && + typeof v.len === "number" && + ("metadata" in v || "mime_type" in v) + ) { + return `[attachment id=${v.id} len=${v.len}]`; + } + return JSON.stringify(value); + } + return String(value); +} diff --git a/src/render/html.ts b/src/render/html.ts new file mode 100644 index 0000000..7cfc491 --- /dev/null +++ b/src/render/html.ts @@ -0,0 +1,53 @@ +import { cellText, collectColumns } from "./columns.js"; +import { stripControlChars } from "./sanitize.js"; + +function esc(s: string): string { + return stripControlChars(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Self-contained HTML report: embedded CSS (zebra rows, sticky header, + * dark-mode aware), no external assets. Nested values render as compact + * JSON in . Safe to open from disk or paste into a page. + */ +export function renderHtml(rows: Record[]): string { + const cols = collectColumns(rows); + const header = cols.map((c) => `${esc(c)}`).join(""); + const body = rows + .map((row) => `${cols.map((c) => `${esc(cellText(row[c]))}`).join("")}`) + .join("\n"); + const count = `${rows.length} row${rows.length === 1 ? "" : "s"}`; + + return ` + + + + + +DQL results — ${count} + + + + +${header} + +${body} + +
+
${count} · generated by dittosh
+ +`; +} diff --git a/src/render/markdown.ts b/src/render/markdown.ts new file mode 100644 index 0000000..6335817 --- /dev/null +++ b/src/render/markdown.ts @@ -0,0 +1,23 @@ +import { cellText, collectColumns } from "./columns.js"; +import { stripControlChars } from "./sanitize.js"; + +/** + * GitHub-flavored markdown table: `_id` first column, union-of-keys header, + * nested values as compact JSON. Pipes are escaped, cell newlines become + *
(a raw newline would split the table row). + */ +export function renderMarkdown(rows: Record[]): string { + if (rows.length === 0) return "(no rows)"; + + const cols = collectColumns(rows); + const esc = (s: string) => stripControlChars(s).replace(/\|/g, "\\|").replace(/\r?\n/g, "
"); + + const lines = [ + `| ${cols.map((c) => esc(c)).join(" | ")} |`, + `| ${cols.map(() => "---").join(" | ")} |`, + ]; + for (const row of rows) { + lines.push(`| ${cols.map((c) => esc(cellText(row[c]))).join(" | ")} |`); + } + return lines.join("\n"); +} diff --git a/src/render/output.ts b/src/render/output.ts index 4d51f3b..fb80c8f 100644 --- a/src/render/output.ts +++ b/src/render/output.ts @@ -1,13 +1,18 @@ import path from "node:path"; import { renderCsv } from "./csv.js"; +import { renderHtml } from "./html.js"; +import { renderMarkdown } from "./markdown.js"; import { renderTable } from "./table.js"; +import { renderVertical } from "./vertical.js"; -export type OutputFormat = "table" | "json" | "csv"; +export type OutputFormat = "table" | "json" | "csv" | "markdown" | "html" | "vertical"; + +const ALL_FORMATS: OutputFormat[] = ["table", "json", "csv", "markdown", "html", "vertical"]; export class FormatError extends Error { readonly exitCode = 2; constructor(flag: string) { - super(`--format must be one of table, json, csv — got "${flag}"`); + super(`--format must be one of ${ALL_FORMATS.join(", ")} — got "${flag}"`); this.name = "FormatError"; } } @@ -18,18 +23,33 @@ export function resolveFormat( isTTY: boolean = process.stdout.isTTY, ): OutputFormat { if (flag === undefined) return isTTY ? "table" : "json"; - if (flag === "table" || flag === "json" || flag === "csv") return flag; + if ((ALL_FORMATS as string[]).includes(flag)) return flag as OutputFormat; throw new FormatError(flag); } -export function renderRows(rows: Record[], format: OutputFormat): string { +export interface RenderOptions { + /** Terminal columns, for table fitting. Undefined = never truncate (files/pipes). */ + maxWidth?: number; +} + +export function renderRows( + rows: Record[], + format: OutputFormat, + opts?: RenderOptions, +): string { switch (format) { case "json": return JSON.stringify(rows, null, 2); case "csv": return renderCsv(rows); + case "markdown": + return renderMarkdown(rows); + case "html": + return renderHtml(rows); + case "vertical": + return renderVertical(rows); case "table": - return renderTable(rows); + return renderTable(rows, { maxWidth: opts?.maxWidth }); } } @@ -39,5 +59,7 @@ export function formatForOutFile(outPath: string, explicit?: string): OutputForm const ext = path.extname(outPath).toLowerCase(); if (ext === ".json") return "json"; if (ext === ".csv") return "csv"; + if (ext === ".md" || ext === ".markdown") return "markdown"; + if (ext === ".html" || ext === ".htm") return "html"; return "table"; } diff --git a/src/render/pager.ts b/src/render/pager.ts new file mode 100644 index 0000000..d226139 --- /dev/null +++ b/src/render/pager.ts @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process"; + +export interface PageOptions { + /** --no-pager flag. */ + disabled?: boolean; + /** Default: process.stdout.isTTY. */ + isTTY?: boolean; + /** Default: process.stdout.rows ?? 24. */ + termRows?: number; + /** Default: process.env (DITTOSH_NO_PAGER, PAGER). */ + env?: NodeJS.ProcessEnv; + /** Injected in tests. */ + spawn?: typeof spawnSync; +} + +/** + * Page long output through $PAGER (or `less -SRF`) when stdout is a TTY and + * the text exceeds the terminal height. Returns true when the pager showed + * the text (caller skips its own print); false → caller prints directly. + * Opt-outs: --no-pager, DITTOSH_NO_PAGER=1/true/yes. Platforms without a + * pager (Windows has no less) fall back to direct printing. + */ +export function pageIfLong(text: string, opts?: PageOptions): boolean { + const env = opts?.env ?? process.env; + if (opts?.disabled) return false; + const noPager = env.DITTOSH_NO_PAGER?.toLowerCase(); + if (noPager === "1" || noPager === "true" || noPager === "yes") return false; + if (!(opts?.isTTY ?? process.stdout.isTTY)) return false; + // A 0-row terminal is degenerate (some ptys report 0x0) — treat as unknown. + const rows = opts?.termRows ?? (process.stdout.rows || 24); + if (text.split("\n").length <= rows) return false; + + const spawn = opts?.spawn ?? spawnSync; + const pager = env.PAGER?.trim(); + // $PAGER may embed args ("less -S") → run it through the shell. + const res = pager + ? spawn(pager, { input: text, stdio: ["pipe", "inherit", "inherit"], shell: true }) + : spawn("less", ["-SRF"], { input: text, stdio: ["pipe", "inherit", "inherit"] }); + // Spawn failure (no less on this platform) → caller prints. A non-zero + // pager exit still means the content was shown — don't double-print. + return !res.error; +} diff --git a/src/render/table.ts b/src/render/table.ts index 0cce849..fcda2dd 100644 --- a/src/render/table.ts +++ b/src/render/table.ts @@ -1,5 +1,6 @@ import chalk from "chalk"; import stringWidth from "string-width"; +import { cellText, collectColumns } from "./columns.js"; import { sanitizeCell } from "./sanitize.js"; /** Display width (accounts for CJK wide chars and emoji; strips ANSI codes). */ @@ -7,72 +8,129 @@ function visibleLength(s: string): number { return stringWidth(s); } -function cell(value: unknown): string { +export interface TableOptions { + /** + * Max total table width (terminal columns). Undefined = never truncate + * (files/pipes get full-fidelity tables). When set, cells are hard-capped + * at CELL_CAP and columns shrink to fit, ellipsizing with "…". + */ + maxWidth?: number; +} + +/** Hard per-cell cap when fitting to a terminal — long text (plots, JSON) stays glanceable. */ +const CELL_CAP = 60; +/** Narrowest a column may shrink to when fitting the terminal width. */ +const MIN_COL_WIDTH = 6; + +type CellKind = "null" | "number" | "text"; + +function kindOf(value: unknown): CellKind { if (value === null) return "null"; - if (value === undefined) return ""; - if (typeof value === "object") { - const v = value as Record; - // Ditto attachment handles surface as objects with an id + len. - if ( - typeof v.id === "string" && - typeof v.len === "number" && - ("metadata" in v || "mime_type" in v) - ) { - return `[attachment id=${sanitizeCell(v.id)} len=${v.len}]`; - } - return sanitizeCell(JSON.stringify(value)); + if (typeof value === "number" || typeof value === "bigint") return "number"; + return "text"; +} + +/** Truncate to `w` display columns, ending with "…" when anything was cut. */ +function ellipsize(s: string, w: number): string { + if (visibleLength(s) <= w) return s; + if (w <= 1) return "…".slice(0, Math.max(w, 0)); + const target = w - 1; + let out = ""; + let width = 0; + for (const ch of s) { + const cw = visibleLength(ch); + if (width + cw > target) break; + out += ch; + width += cw; } - return sanitizeCell(String(value)); + return `${out}…`; } /** * Render rows as an ASCII table: `_id` column first, then the union of all - * other keys in first-seen order. + * other keys in first-seen order. Numbers right-align; nulls are dimmed. + * Pass maxWidth (terminal columns) to fit the table to the terminal. */ -export function renderTable(rows: Record[]): string { +export function renderTable(rows: Record[], opts?: TableOptions): string { if (rows.length === 0) return "(no rows)"; - const cols: string[] = []; - const seen = new Set(); - if (rows.some((r) => "_id" in r)) { - cols.push("_id"); - seen.add("_id"); - } - for (const row of rows) { - for (const key of Object.keys(row)) { - if (!seen.has(key)) { - seen.add(key); - cols.push(key); - } - } - } + const cols = collectColumns(rows); // Sanitize keys BEFORE measuring — control chars measure 0 wide but render // as markers (⏎/⇥), which would make repeat counts go negative (crash). const header = cols.map((c) => sanitizeCell(c)); + const fitting = opts?.maxWidth !== undefined; + + const data = rows.map((row) => + cols.map((c) => { + const raw = sanitizeCell(cellText(row[c])); + return { + kind: kindOf(row[c]), + text: fitting ? ellipsize(raw, CELL_CAP) : raw, + }; + }), + ); - const data = rows.map((row) => cols.map((c) => cell(row[c]))); // Loop, not spread — Math.max(...spread) overflows the call stack past ~150k rows. const widths = header.map((h, i) => { let w = visibleLength(h); for (const r of data) { - const rw = visibleLength(r[i] ?? ""); + const rw = visibleLength(r[i]!.text); if (rw > w) w = rw; } return w; }); + // Fit to the terminal: shrink the widest column one char at a time until + // the table fits or every column is at the floor (too many columns to fit + // is accepted — the alternative is dropping data silently). + if (fitting) { + const maxWidth = opts!.maxWidth!; + const total = () => widths.reduce((a, b) => a + b, 0) + 3 * widths.length + 1; + for (;;) { + if (total() <= maxWidth) break; + let widest = -1; + for (let i = 0; i < widths.length; i++) { + if (widths[i]! > MIN_COL_WIDTH && (widest === -1 || widths[i]! > widths[widest]!)) { + widest = i; + } + } + if (widest === -1) break; + widths[widest]!--; + } + for (const r of data) { + for (let i = 0; i < r.length; i++) { + r[i]!.text = ellipsize(r[i]!.text, widths[i]!); + } + } + } + const line = (left: string, mid: string, right: string) => left + widths.map((w) => "─".repeat(w + 2)).join(mid) + right; - const row = (cells: string[]) => + const renderRow = (cells: { kind: CellKind; text: string }[]) => "│ " + - cells.map((c, i) => c + " ".repeat((widths[i] ?? 0) - visibleLength(c))).join(" │ ") + + cells + .map((c, i) => { + // Math.max: a cell wider than its column can only happen pre-fit + // (headers are ellipsized below) — never let repeat() go negative. + const gap = Math.max(0, (widths[i] ?? 0) - visibleLength(c.text)); + const padded = c.kind === "number" ? " ".repeat(gap) + c.text : c.text + " ".repeat(gap); + return c.kind === "null" ? chalk.dim(padded) : padded; + }) + .join(" │ ") + " │"; const out: string[] = []; out.push(line("┌", "┬", "┐")); - out.push(row(header.map((h) => chalk.bold(h)))); + out.push( + renderRow( + header.map((h, i) => ({ + kind: "text" as const, + text: chalk.bold(fitting ? ellipsize(h, widths[i]!) : h), + })), + ), + ); out.push(line("├", "┼", "┤")); - for (const r of data) out.push(row(r)); + for (const r of data) out.push(renderRow(r)); out.push(line("└", "┴", "┘")); out.push(`${rows.length} row${rows.length === 1 ? "" : "s"}`); return out.join("\n"); diff --git a/src/render/vertical.ts b/src/render/vertical.ts new file mode 100644 index 0000000..1bb1798 --- /dev/null +++ b/src/render/vertical.ts @@ -0,0 +1,26 @@ +import chalk from "chalk"; +import { cellText, collectColumns } from "./columns.js"; +import { sanitizeCell } from "./sanitize.js"; + +/** + * Vertical/expanded display (psql \x-style): one block per row, fields as + * `key │ value` lines. Values are never truncated — the point of this mode + * is seeing wide rows (nested JSON, long text) in full. + */ +export function renderVertical(rows: Record[]): string { + if (rows.length === 0) return "(no rows)"; + + const cols = collectColumns(rows); + const keyWidth = Math.max(...cols.map((c) => sanitizeCell(c).length)); + + const out: string[] = []; + rows.forEach((row, i) => { + out.push(chalk.dim(`── row ${i + 1} ${"─".repeat(20)}`)); + for (const c of cols) { + const key = sanitizeCell(c).padEnd(keyWidth); + out.push(`${chalk.bold(key)} │ ${sanitizeCell(cellText(row[c]))}`); + } + }); + out.push(`${rows.length} row${rows.length === 1 ? "" : "s"}`); + return out.join("\n"); +} diff --git a/tests/e2e/delete-store.test.ts b/tests/e2e/delete-store.test.ts new file mode 100644 index 0000000..fe5c27f --- /dev/null +++ b/tests/e2e/delete-store.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; +import { hasDevCredentials, NO_CREDENTIALS, rmrf, tmpDataDir } from "../helpers/credentials.js"; + +const ROOT = path.resolve(import.meta.dirname, "../.."); + +function cli(args: string[]) { + return execa(process.execPath, ["--import", "tsx", "src/cli/index.ts", ...args], { + cwd: ROOT, + reject: false, + all: true, + }); +} + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +describe.skipIf(!hasDevCredentials)(`e2e: dql delete-store (${NO_CREDENTIALS})`, () => { + it("deletes an initialized store with -y (dir and lock file gone)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'d1','title':'Doomed'}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + expect(fs.existsSync(path.join(dir, "__ditto_lock_file"))).toBe(true); + + const r = (await cli(["dql", "delete-store", "-y", "-d", dir])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Deleted the store"); + expect(fs.existsSync(dir)).toBe(false); + } finally { + rmrf(dir); + } + }); + + it("without -y it refuses (exit 2) and the store is untouched", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'d1'}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const r = (await cli(["dql", "delete-store", "-d", dir])) as unknown as RunResult; + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("--yes"); + expect(fs.existsSync(path.join(dir, "__ditto_lock_file"))).toBe(true); + } finally { + rmrf(dir); + } + }); + + it("a missing dir is a no-op (exit 0)", async () => { + const dir = path.join(tmpDataDir("ditto-e2e-"), "never-created"); + const r = (await cli(["dql", "delete-store", "-y", "-d", dir])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("nothing to delete"); + rmrf(path.dirname(dir)); + }); + + it("refuses to delete the home directory (exit 2)", async () => { + const r = (await cli([ + "dql", + "delete-store", + "-y", + "-d", + os.homedir(), + ])) as unknown as RunResult; + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain("Refusing"); + expect(fs.existsSync(os.homedir())).toBe(true); + }); +}); diff --git a/tests/e2e/import.test.ts b/tests/e2e/import.test.ts new file mode 100644 index 0000000..5a6b1aa --- /dev/null +++ b/tests/e2e/import.test.ts @@ -0,0 +1,180 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execa } from "execa"; +import { describe, expect, it } from "vitest"; +import { hasDevCredentials, NO_CREDENTIALS, rmrf, tmpDataDir } from "../helpers/credentials.js"; + +const ROOT = path.resolve(import.meta.dirname, "../.."); + +function cli(args: string[]) { + return execa(process.execPath, ["--import", "tsx", "src/cli/index.ts", ...args], { + cwd: ROOT, + reject: false, + all: true, + }); +} + +interface RunResult { + exitCode: number; + stdout: string; + stderr: string; +} + +describe.skipIf(!hasDevCredentials)(`e2e: dql import (${NO_CREDENTIALS})`, () => { + it("imports a JSON array and queries it back", async () => { + const dir = tmpDataDir("ditto-e2e-"); + const file = path.join(dir, "in.json"); + fs.writeFileSync( + file, + JSON.stringify([ + { _id: "imp_1", name: "Brass Hammer", price: 24.99, tags: ["hand", "clearance"] }, + { _id: "imp_2", name: "Cordless Drill", price: 129.0 }, + ]), + "utf8", + ); + try { + const r = (await cli([ + "dql", + "import", + file, + "imported_products", + "-d", + dir, + ])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain("Imported 2 documents into imported_products"); + + const sel = (await cli([ + "dql", + "SELECT name, price, tags FROM imported_products ORDER BY _id", + "-d", + dir, + ])) as unknown as RunResult; + expect(JSON.parse(sel.stdout)).toEqual([ + { name: "Brass Hammer", price: 24.99, tags: ["hand", "clearance"] }, + { name: "Cordless Drill", price: 129 }, + ]); + } finally { + rmrf(dir); + } + }); + + it("re-importing the same file is idempotent (upsert)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + const file = path.join(dir, "in.json"); + fs.writeFileSync(file, JSON.stringify([{ _id: "imp_1", v: 1 }]), "utf8"); + try { + await cli(["dql", "import", file, "things", "-d", dir]); + const again = (await cli([ + "dql", + "import", + file, + "things", + "-d", + dir, + ])) as unknown as RunResult; + expect(again.exitCode).toBe(0); + const count = (await cli([ + "dql", + "SELECT count(*) AS n FROM things", + "-d", + dir, + ])) as unknown as RunResult; + expect(JSON.parse(count.stdout)).toEqual([{ n: 1 }]); + } finally { + rmrf(dir); + } + }); + + it("accepts NDJSON (one object per line)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + const file = path.join(dir, "in.ndjson"); + fs.writeFileSync(file, '{"_id":"n1","v":1}\n{"_id":"n2","v":2}\n', "utf8"); + try { + const r = (await cli(["dql", "import", file, "nd", "-d", dir])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + const count = (await cli([ + "dql", + "SELECT count(*) AS n FROM nd", + "-d", + dir, + ])) as unknown as RunResult; + expect(JSON.parse(count.stdout)).toEqual([{ n: 2 }]); + } finally { + rmrf(dir); + } + }); + + it("documents without _id get a generated UUID", async () => { + const dir = tmpDataDir("ditto-e2e-"); + const file = path.join(dir, "in.json"); + fs.writeFileSync(file, JSON.stringify([{ name: "no id here" }]), "utf8"); + try { + const r = (await cli(["dql", "import", file, "gen", "-d", dir])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + const sel = (await cli([ + "dql", + "SELECT _id, name FROM gen", + "-d", + dir, + ])) as unknown as RunResult; + const rows = JSON.parse(sel.stdout); + expect(rows[0].name).toBe("no id here"); + expect(rows[0]._id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/); + } finally { + rmrf(dir); + } + }); + + it("bad inputs are usage errors (exit 2)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + const good = path.join(dir, "ok.json"); + fs.writeFileSync(good, '[{"_id":"1"}]', "utf8"); + const bad = path.join(dir, "bad.json"); + fs.writeFileSync(bad, "not json at all", "utf8"); + try { + const missing = (await cli([ + "dql", + "import", + path.join(dir, "nope.json"), + "things", + "-d", + dir, + ])) as unknown as RunResult; + expect(missing.exitCode).toBe(2); + expect(missing.stderr).toContain("Cannot read file"); + + const invalid = (await cli([ + "dql", + "import", + bad, + "things", + "-d", + dir, + ])) as unknown as RunResult; + expect(invalid.exitCode).toBe(2); + + const badName = (await cli([ + "dql", + "import", + good, + "x;DROP TABLE y", + "-d", + dir, + ])) as unknown as RunResult; + expect(badName.exitCode).toBe(2); + expect(badName.stderr).toContain("invalid collection name"); + + // Nothing was written on the usage-error paths. + const count = (await cli([ + "dql", + "SELECT count(*) AS n FROM things", + "-d", + dir, + ])) as unknown as RunResult; + expect(JSON.parse(count.stdout)).toEqual([{ n: 0 }]); + } finally { + rmrf(dir); + } + }); +}); diff --git a/tests/e2e/modes.test.ts b/tests/e2e/modes.test.ts index fc5b7d8..c329d68 100644 --- a/tests/e2e/modes.test.ts +++ b/tests/e2e/modes.test.ts @@ -330,4 +330,197 @@ describe.skipIf(!hasDevCredentials)(`e2e: ditto dql input modes (${NO_CREDENTIAL rmrf(dir); } }); + + it("--format markdown/html/vertical render on stdout", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'f1','title':'Alien','year':1979}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const md = (await cli([ + "dql", + "SELECT * FROM movies", + "-d", + dir, + "--format", + "markdown", + ])) as unknown as RunResult; + expect(md.exitCode).toBe(0); + expect(md.stdout).toContain("| _id | title | year |"); + expect(md.stdout).toContain("| --- | --- | --- |"); + expect(md.stdout).toContain("| f1 | Alien | 1979 |"); + + const html = (await cli([ + "dql", + "SELECT * FROM movies", + "-d", + dir, + "--format", + "html", + ])) as unknown as RunResult; + expect(html.exitCode).toBe(0); + expect(html.stdout).toContain(""); + expect(html.stdout).toContain(""); + + const vert = (await cli([ + "dql", + "SELECT * FROM movies", + "-d", + dir, + "--format", + "vertical", + ])) as unknown as RunResult; + expect(vert.exitCode).toBe(0); + expect(vert.stdout).toContain("row 1"); + expect(vert.stdout).toContain("title │ Alien"); + } finally { + rmrf(dir); + } + }); + + it("-o infers markdown/html from the file extension", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'o1','title':'Out','year':2001}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const mdPath = path.join(dir, "results.md"); + const md = (await cli([ + "dql", + "SELECT * FROM movies", + "-d", + dir, + "-o", + mdPath, + ])) as unknown as RunResult; + expect(md.exitCode).toBe(0); + expect(md.stdout).toContain("(markdown)"); + expect(fs.readFileSync(mdPath, "utf8")).toContain("| _id | title | year |"); + + const htmlPath = path.join(dir, "results.html"); + const html = (await cli([ + "dql", + "SELECT * FROM movies", + "-d", + dir, + "-o", + htmlPath, + ])) as unknown as RunResult; + expect(html.exitCode).toBe(0); + expect(html.stdout).toContain("(html)"); + expect(fs.readFileSync(htmlPath, "utf8")).toContain(""); + } finally { + rmrf(dir); + } + }); + + it("--args - reads params from stdin (the jq pipeline form)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'p1','title':'Alien','year':1979}), ({'_id':'p2','title':'Toy Story','year':1995}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const r = (await cli( + ["dql", "SELECT title FROM movies WHERE year > :minYear", "-d", dir, "--args", "-"], + { input: '{"minYear":1980}' }, + )) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([{ title: "Toy Story" }]); + } finally { + rmrf(dir); + } + }); + + it("--args @file reads params from a file", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'p1','title':'Alien','year':1979}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const paramsFile = path.join(dir, "params.json"); + fs.writeFileSync(paramsFile, '{"maxYear":1990}', "utf8"); + const r = (await cli([ + "dql", + "SELECT title FROM movies WHERE year < :maxYear", + "-d", + dir, + "--args", + `@${paramsFile}`, + ])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([{ title: "Alien" }]); + } finally { + rmrf(dir); + } + }); + + it("--args source errors are usage errors (exit 2)", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + // stdin is the statement batch — --args - has nothing to read from + const conflict = (await cli(["dql", "-d", dir, "--args", "-"], { + input: "SELECT 1;", + })) as unknown as RunResult; + expect(conflict.exitCode).toBe(2); + expect(conflict.stderr).toContain("consumes stdin"); + + const badJson = (await cli(["dql", "SELECT 1", "-d", dir, "--args", "-"], { + input: "not json", + })) as unknown as RunResult; + expect(badJson.exitCode).toBe(2); + + const array = (await cli(["dql", "SELECT 1", "-d", dir, "--args", "-"], { + input: "[1,2]", + })) as unknown as RunResult; + expect(array.exitCode).toBe(2); + + const missing = (await cli([ + "dql", + "SELECT 1", + "-d", + dir, + "--args", + "@/no/such/file.json", + ])) as unknown as RunResult; + expect(missing.exitCode).toBe(2); + expect(missing.stderr).toContain("cannot read"); + } finally { + rmrf(dir); + } + }); + + it("--no-pager is accepted", async () => { + const dir = tmpDataDir("ditto-e2e-"); + try { + await cli([ + "dql", + "INSERT INTO movies DOCUMENTS ({'_id':'np1','title':'Alien'}) ON ID CONFLICT DO UPDATE", + "-d", + dir, + ]); + const r = (await cli([ + "dql", + "SELECT title FROM movies", + "-d", + dir, + "--no-pager", + ])) as unknown as RunResult; + expect(r.exitCode).toBe(0); + expect(JSON.parse(r.stdout)).toEqual([{ title: "Alien" }]); + } finally { + rmrf(dir); + } + }); }); diff --git a/tests/e2e/system.test.ts b/tests/e2e/system.test.ts index 305eb66..e53df59 100644 --- a/tests/e2e/system.test.ts +++ b/tests/e2e/system.test.ts @@ -28,6 +28,7 @@ describe("e2e: ditto version / update / banner", () => { expect(r.exitCode).toBe(0); for (const key of [ "version", + "ditto_sdk", "channel", "update", "token_expires", @@ -42,6 +43,7 @@ describe("e2e: ditto version / update / banner", () => { expect(j.exitCode).toBe(0); const parsed = JSON.parse(j.stdout); expect(parsed).toHaveProperty("version"); + expect(parsed).toHaveProperty("ditto_sdk"); expect(parsed).toHaveProperty("channel"); const bad = (await cli(["version", "--format", "yaml"])) as unknown as RunResult; diff --git a/tests/integration/session.test.ts b/tests/integration/session.test.ts index 336d235..76d6582 100644 --- a/tests/integration/session.test.ts +++ b/tests/integration/session.test.ts @@ -92,6 +92,24 @@ describe.skipIf(!hasDevCredentials)(`integration: DittoSession (${NO_CREDENTIALS expect(lock.detail).toContain("locked by another process"); }); + it("deleteStore refuses a store this process holds open (exit 4), real probe", async () => { + const { deleteStore } = await import("../../src/cli/groups/dql/delete-store.js"); + // session holds the lock on dataDir (opened in beforeAll) + const r = await deleteStore({ dataDir, yes: true }); + expect(r.code).toBe(4); + expect(fs.existsSync(dataDir)).toBe(true); + }); + + it("deleteStore deletes an unlocked store end to end (real probe)", async () => { + const { deleteStore } = await import("../../src/cli/groups/dql/delete-store.js"); + const dir = tmpDataDir("ditto-delete-"); + const s = await DittoSession.open(loadIdentity(), dir); + await s.close(); + const r = await deleteStore({ dataDir: dir, yes: true }); + expect(r.code).toBe(0); + expect(fs.existsSync(dir)).toBe(false); + }); + it("a read-only data dir maps to DataDirError (exit 3)", async () => { const roDir = tmpDataDir("ditto-ro-"); fs.chmodSync(roDir, 0o555); diff --git a/tests/unit/advise.test.ts b/tests/unit/advise.test.ts index c82eabc..ea5e297 100644 --- a/tests/unit/advise.test.ts +++ b/tests/unit/advise.test.ts @@ -67,6 +67,28 @@ describe("renderAdvice", () => { expect(out).toContain("--apply"); }); + it("prints a copy-pasteable apply command with the analyzed statement", () => { + const out = renderAdvice(extractQueryAdvice([ADVICE_ROW])!); + expect(out).toContain( + `apply with: dittosh dql --advise --apply "SELECT * FROM movies WHERE rated = 'PG'"`, + ); + }); + + it("shell-escapes the analyzed statement in the apply command", () => { + const out = renderAdvice({ + statement: 'SELECT * FROM m WHERE t = "x" AND p = $1', + suggestedIndexes: [{ collection: "m", statement: "CREATE INDEX i ON m (t)" }], + }); + expect(out).toContain('--apply "SELECT * FROM m WHERE t = \\"x\\" AND p = \\$1"'); + }); + + it("falls back to the placeholder when no statement was echoed", () => { + const out = renderAdvice({ + suggestedIndexes: [{ collection: "m", statement: "CREATE INDEX i ON m (t)" }], + }); + expect(out).toContain('""'); + }); + it("renders the empty state with outcome text", () => { const out = renderAdvice({ suggestedIndexes: [], outcome: "no keys to advise on" }); expect(out).toContain("no index suggestions"); diff --git a/tests/unit/cli-system.test.ts b/tests/unit/cli-system.test.ts index 657da0c..8d5ea4b 100644 --- a/tests/unit/cli-system.test.ts +++ b/tests/unit/cli-system.test.ts @@ -59,6 +59,7 @@ describe("ditto version", () => { const out = stdout(); for (const key of [ "version", + "ditto_sdk", "channel", "update", "token_expires", @@ -84,6 +85,7 @@ describe("ditto version", () => { await program.parseAsync(["node", "ditto", "version", "--format", "json"]); const parsed = JSON.parse(stdout()); expect(parsed).toHaveProperty("version"); + expect(parsed).toHaveProperty("ditto_sdk"); expect(parsed).toHaveProperty("channel"); expect(parsed).toHaveProperty("token_expires"); }); diff --git a/tests/unit/default-command.test.ts b/tests/unit/default-command.test.ts index 7e891e3..b15773d 100644 --- a/tests/unit/default-command.test.ts +++ b/tests/unit/default-command.test.ts @@ -25,7 +25,15 @@ describe("rewriteDefaultSubcommand", () => { }); it("leaves known subcommands alone", () => { - for (const sub of ["exec", "doctor", "collections", "indexes", "dataset"]) { + for (const sub of [ + "exec", + "doctor", + "collections", + "indexes", + "dataset", + "delete-store", + "import", + ]) { expect(rewriteDefaultSubcommand(["dql", sub, "-d", "/tmp/x"])).toEqual([ "dql", sub, diff --git a/tests/unit/delete-store.test.ts b/tests/unit/delete-store.test.ts new file mode 100644 index 0000000..5e56cd1 --- /dev/null +++ b/tests/unit/delete-store.test.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { deleteStore } from "../../src/cli/groups/dql/delete-store.js"; +import { LockError } from "../../src/ditto/session.js"; +import { rmrf, tmpDataDir } from "../helpers/credentials.js"; + +/** A fake initialized store dir (lock file + a data file). */ +function fakeStore(): string { + const dir = tmpDataDir("ditto-delete-"); + fs.writeFileSync(path.join(dir, "__ditto_lock_file"), ""); + fs.writeFileSync(path.join(dir, "data.sqlite"), "x"); + return dir; +} + +describe("deleteStore", () => { + it("missing dir → 0, nothing to delete", async () => { + const dir = path.join(tmpDataDir("ditto-delete-"), "nope"); + const r = await deleteStore({ dataDir: dir, yes: true }); + expect(r.code).toBe(0); + expect(r.message).toContain("nothing to delete"); + rmrf(path.dirname(dir)); + }); + + it("without --yes → 2 and the store is untouched", async () => { + const dir = fakeStore(); + try { + const r = await deleteStore({ dataDir: dir }); + expect(r.code).toBe(2); + expect(r.message).toContain("--yes"); + expect(fs.existsSync(path.join(dir, "data.sqlite"))).toBe(true); + } finally { + rmrf(dir); + } + }); + + it("--yes deletes the whole directory (lock file, data, everything)", async () => { + const dir = fakeStore(); + const probeLock = vi.fn(async () => {}); + const r = await deleteStore({ dataDir: dir, yes: true, probeLock }); + expect(r.code).toBe(0); + expect(r.message).toContain("Deleted"); + expect(fs.existsSync(dir)).toBe(false); + expect(probeLock).toHaveBeenCalledWith(dir); + }); + + it("skips the lock probe when no lock file exists", async () => { + const dir = tmpDataDir("ditto-delete-"); // no __ditto_lock_file + const probeLock = vi.fn(async () => {}); + const r = await deleteStore({ dataDir: dir, yes: true, probeLock }); + expect(r.code).toBe(0); + expect(probeLock).not.toHaveBeenCalled(); + }); + + it("a held lock → 4 and the store is untouched", async () => { + const dir = fakeStore(); + try { + const r = await deleteStore({ + dataDir: dir, + yes: true, + probeLock: async () => { + throw new LockError(dir); + }, + }); + expect(r.code).toBe(4); + expect(r.message).toContain("in use by another dittosh process"); + expect(fs.existsSync(dir)).toBe(true); + } finally { + rmrf(dir); + } + }); + + it("non-lock probe failures (expired token, SDK unavailable) don't block deletion", async () => { + const dir = fakeStore(); + const r = await deleteStore({ + dataDir: dir, + yes: true, + probeLock: async () => { + throw new Error("License rejected: token expired"); + }, + }); + expect(r.code).toBe(0); + expect(fs.existsSync(dir)).toBe(false); + }); + + it("refuses to delete root, home, or the cwd", async () => { + for (const dir of [path.parse(process.cwd()).root, os.homedir(), process.cwd()]) { + const r = await deleteStore({ dataDir: dir, yes: true }); + expect(r.code).toBe(2); + expect(r.message).toContain("Refusing"); + } + }); + + it("bogus -d and bogus DITTOSH_DATA_DIR are usage errors", async () => { + expect((await deleteStore({ dataDir: "--", yes: true })).code).toBe(2); + expect((await deleteStore({ env: { DITTOSH_DATA_DIR: "--" }, yes: true })).code).toBe(2); + }); + + it("honors DITTOSH_DATA_DIR when -d is absent", async () => { + const dir = fakeStore(); + const r = await deleteStore({ + env: { DITTOSH_DATA_DIR: dir }, + yes: true, + probeLock: async () => {}, + }); + expect(r.code).toBe(0); + expect(fs.existsSync(dir)).toBe(false); + }); + + it("rm failure → 3", async () => { + const dir = fakeStore(); + try { + const r = await deleteStore({ + dataDir: dir, + yes: true, + probeLock: async () => {}, + rm: () => { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + }, + }); + expect(r.code).toBe(3); + expect(r.message).toContain("permission denied"); + } finally { + rmrf(dir); + } + }); +}); diff --git a/tests/unit/html.test.ts b/tests/unit/html.test.ts new file mode 100644 index 0000000..e8ba223 --- /dev/null +++ b/tests/unit/html.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { renderHtml } from "../../src/render/html.js"; + +describe("renderHtml", () => { + it("renders a self-contained HTML document", () => { + const out = renderHtml([{ _id: "1", title: "Alien" }]); + expect(out).toContain(""); + expect(out).toContain("
AlienOut