@carllee1983/dbcli 1.58.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -334,9 +334,91 @@ dbcli query "SELECT day, dau FROM dau_daily" --ui # open in brows
334
334
  dbcli query "SELECT * FROM orders" --format html > orders.html # pipe to stdout
335
335
  ```
336
336
 
337
- **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--recovery`
337
+ **Options:** `--format <table|json|csv|html>`, `--ui` (open the dashboard in the system browser; implies `--format html`), `--limit <number>`, `--no-limit`, `--collection <name>` (MongoDB / Elasticsearch), `--index <name>` (Elasticsearch alias for `--collection`), `--fields <list>`, `--truncate <number>` / `--no-truncate`, `-f, --query-file <path>`, `--use <name[,name]>`, `--slow-ms <number>`, `--yes`, `--recovery`
338
338
  **Permission:** query-only+ (Redis: per-command; Elasticsearch: per HTTP method/path)
339
339
 
340
+ #### Write confirmation gate (2.0.0)
341
+
342
+ A SQL write passes through a two-tier gate before the connection is opened. The gate is
343
+ separate from the permission axis: permission says what the connection may do, the gate
344
+ says whether this particular statement may run right now.
345
+
346
+ | Tier | Statements | Interactive terminal | Non-interactive (or `--format json`) |
347
+ | :--- | :--- | :--- | :--- |
348
+ | One | `INSERT`, `UPDATE` / `DELETE` **with** a `WHERE` or `LIMIT`, `CREATE`, `ALTER` | Summary + `y/N`; `--yes` skips it | Runs, exactly as before |
349
+ | Two | `UPDATE` / `DELETE` with **no** `WHERE`, `DROP`, `TRUNCATE`, unparseable statements, several statements in one string, a write nested inside another statement, a destructive `MERGE` | Type the target table name; **no flag skips it** | **Refused**: exit `1`, nothing sent to the database |
350
+
351
+ A refusal message names a machine-readable reason — `reason=no_where`, `reason=multi_table`,
352
+ `reason=ddl_destruction`, `reason=unparseable`, `reason=multiple_statements`, `reason=nested_write` — so a caller can tell it apart from a
353
+ connection failure or a permission denial.
354
+
355
+ **A write that joins another table is tier two, whatever its `WHERE` says.** Whether such a
356
+ write is limited to particular rows is not a property of the statement:
357
+ `DELETE p FROM p JOIN o ON p.id = o.ref WHERE o.x > 0` deleted 2 of 5 rows against one
358
+ dataset and all 2000 against another, and a join's `ON` necessarily names the target, so it
359
+ proves nothing. Five rounds of adversarial measurement went into trying to read this off the
360
+ syntax before the rule became "a second table means tier two" (#80, #93). That refuses the
361
+ standard `UPDATE … FROM`, `UPDATE … JOIN … SET` and multi-table `DELETE` idioms for
362
+ unattended callers: rewrite the other table into a subquery in the `WHERE`, or run them
363
+ where someone can confirm.
364
+
365
+ **A statement is not the keyword it starts with.** PostgreSQL's data-modifying CTEs put an
366
+ arbitrary write in front of the statement the first keyword names, and a `MERGE` carries its
367
+ writes in a `WHEN … THEN` list — measured against a 2000-row table,
368
+ `WITH moved AS (DELETE FROM p RETURNING *) INSERT INTO archive …` and
369
+ `MERGE INTO p USING (SELECT 1 AS x) s ON true WHEN MATCHED THEN DELETE` each emptied it
370
+ while reading as an ordinary `INSERT` (#94, #95). So a write nested inside another statement
371
+ is tier two, `reason=nested_write`, and a `MERGE` is classified by its actions: a
372
+ `THEN DELETE` or `THEN UPDATE` is tier two, `reason=multi_table`, while an insert-only or
373
+ `DO NOTHING` one stays tier one. `ON CONFLICT … DO UPDATE`, `ON DUPLICATE KEY UPDATE` and
374
+ `INSERT … SELECT` are untouched by this — they are not nested writes, whatever they read
375
+ like. The cost is a CTE that deletes one row by primary key, and the ordinary `MERGE` upsert,
376
+ both refused for unattended callers.
377
+
378
+ **For a single-table write, the `WHERE` has to be about that table.** `UPDATE p SET c = 1
379
+ WHERE id = 1` is an ordinary write; `UPDATE p SET c = (SELECT max(x) FROM o WHERE o.id = 1)`
380
+ is not, because its only `WHERE` restricts the subquery and the write touches every row. A
381
+ *correlated* reference back to the target does count, so
382
+ `DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.tid = t.id)` is tier one while
383
+ `DELETE FROM t WHERE EXISTS (SELECT 1 FROM o WHERE o.id = 1)` deletes every row and is tier
384
+ two. A qualifier the subquery binds itself — including the target's own name, as in
385
+ `DELETE FROM sessions WHERE EXISTS (SELECT 1 FROM sessions WHERE …)` — is about the
386
+ subquery's rows, not the write.
387
+
388
+ This is a lower bound, not a proof: `WHERE id IS NOT NULL` names the target and still touches
389
+ every row, and no static check settles that. What it rules out is the class where nothing in
390
+ the condition is about the table being written.
391
+
392
+ **When the parser cannot read the statement**, there is no tree to judge and the same rule is
393
+ applied to the text: tier one only when the statement reads as a write to one named table
394
+ with a `WHERE` or `LIMIT` and contains no `SELECT`, `TABLE`, `VALUES`, `JOIN`, `USING`, a
395
+ statement-level `WITH`, or an `UPDATE … FROM`. A keyword inside parentheses is not at
396
+ statement level, so `SUBSTRING(x FROM 2)` and `AGAINST ('a' WITH QUERY EXPANSION)` do not
397
+ trip it. This is written as an allowlist because the denylist that preceded it was defeated
398
+ once per review round — `USING`, then `JOIN`, then a CTE, then a subquery, then `TABLE` as a
399
+ subquery.
400
+
401
+ **Escape routes.** For a statement that accepts a `WHERE`, put the intent in the SQL:
402
+
403
+ ```bash
404
+ dbcli query "UPDATE users SET banned = 1" # refused, reason=no_where
405
+ dbcli query "UPDATE users SET banned = 1 WHERE 1=1" # runs — intent is explicit
406
+ dbcli query "DELETE FROM sessions LIMIT 1000" # runs — damage is bounded
407
+ dbcli query "UPDATE users SET banned = 1 WHERE id = 3" --yes # tier one, question skipped
408
+ dbcli query "UPDATE p SET x = 1 FROM o WHERE o.id = 1" # refused — the WHERE is about o, not p
409
+ ```
410
+
411
+ This is deliberately not a flag. `WHERE 1=1` appended to a statement that already has a
412
+ `WHERE` is a syntax error, so a blanket "always add it" habit breaks on the first ordinary
413
+ statement; a flag would be harmless everywhere and therefore added everywhere. For `DROP`
414
+ and `TRUNCATE` there is no clause to add — the connection's `permission` level decides
415
+ whether they are possible at all, and the typed confirmation must come from a person.
416
+
417
+ Every tier-two evaluation is written to the audit log with
418
+ `metadata.write_gate_outcome` (`allowed` / `declined` / `refused`) and
419
+ `metadata.write_gate_reason`. `dbcli audit write-gate` summarizes them — that is
420
+ the measurement ADR 0010 stakes this gate on.
421
+
340
422
  > **Elasticsearch tiers by scope.** A read (`GET` / `HEAD`, plus `POST _search` and
341
423
  > `POST _count`) is query-only. Indexing or updating a document is read-write.
342
424
  > `DELETE /<index>/_doc/<id>` — one document — is data-admin. Everything that removes or
@@ -955,6 +1037,14 @@ dbcli update users --where "id=1" --set '{"name":"Bob"}' --plan --format json
955
1037
  > target primary keys first, then issue one `update` / `delete --where "id=<pk>"` per key.
956
1038
  > (MongoDB `--where` accepts a full JSON filter and is exempt.)
957
1039
 
1040
+ > **Tier two for structured writes (2.0.0)** — a `--where` that matches on no primary key
1041
+ > and no unique index selects an unknown number of rows, so it is treated the same as a
1042
+ > raw statement with no `WHERE`: the target table name must be typed at an interactive
1043
+ > terminal, and a non-interactive run is refused with exit `1` and
1044
+ > `reason=non_unique_where`. `--force` and `--dry-run` do not affect this; `--force` skips
1045
+ > the ordinary confirmation only. Select the primary keys first and write one row at a
1046
+ > time, or run it where a person can confirm it.
1047
+
958
1048
  ### delete
959
1049
 
960
1050
  Delete data from a table.
@@ -1946,6 +2036,7 @@ Audit entries are metadata-only by design — never raw SQL bodies, `--param` va
1946
2036
  | `audit show` | `readonly` | Print a single full entry by id prefix or `--recovery-ref`. |
1947
2037
  | `audit clear` | `local-write` | Delete `<conn>.jsonl` + rotated `.jsonl.1` from local disk. Requires `--yes` or interactive confirm. |
1948
2038
  | `audit health` | `readonly` | Render `AuditLogger.getHealth()` snapshot (writer state, lock state, rotation usage). |
2039
+ | `audit write-gate` | `readonly` | Summarize tier-two write-gate decisions: how often the gate was reached, by which reason, and how it was answered. |
1949
2040
 
1950
2041
  #### `audit tail`
1951
2042
 
@@ -2006,6 +2097,32 @@ Examples:
2006
2097
 
2007
2098
  Output reports: writer enabled/disabled, last write result, file-lock state, rotation cap usage (`max_bytes` / `max_entries`). When `audit.enabled = false` (D1 opt-out), `tail` / `show` / `health` still exit 0 and print `Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.` (E note).
2008
2099
 
2100
+ #### `audit write-gate`
2101
+
2102
+ Answers the question ADR 0010 stakes the two-tier write gate on: is tier two stopping anything, or has everyone routed around it? The data was always written — `metadata.write_gate_tier` / `write_gate_outcome` / `write_gate_reason` on every evaluation — but reading it meant hand-writing jq over the JSONL.
2103
+
2104
+ | Flag | Purpose | Default |
2105
+ |---|---|---|
2106
+ | `--format <fmt>` | `table` \| `json`. | `table` |
2107
+ | `--all` | Merge every connection into one measurement. | off |
2108
+ | `--for-agent` | Shortcut for `--format json`. | off |
2109
+
2110
+ ```bash
2111
+ dbcli audit write-gate
2112
+ dbcli audit write-gate --format json
2113
+ dbcli audit write-gate --all
2114
+ ```
2115
+
2116
+ Reports, over the current connection's retained history (rotated `.jsonl.1` included):
2117
+
2118
+ - `total` — tier-two evaluations, with `range` giving the interval they span.
2119
+ - `outcomes` — `allowed` / `declined` / `refused`. The ratio between them is the line between "the gate is stopping things" and "everyone confirms through it".
2120
+ - `reasons` — which criterion sent the statement to tier two. Every known reason is listed, **including the ones that never fired**: a criterion that triggers zero times is the finding, not a row to omit.
2121
+ - `scanned` + `window` — the denominator. Two decisions in a week and two in a year are different findings.
2122
+ - `tierOne` — counted apart, with its own outcome breakdown, because tier one is recorded only when the operator declined.
2123
+
2124
+ When tier two was never reached, the summary says so and names the conclusion ADR 0010 draws from it (the criterion is wrong, not the gate unnecessary) rather than printing an empty table. An entirely empty audit log is reported as a separate case: it supports no conclusion either way.
2125
+
2009
2126
  #### Boundaries
2010
2127
 
2011
2128
  - Entries are append-only JSONL; rotation triggers at `~10 MB` or `~1000` entries (whichever first). Previous segment is preserved as `.jsonl.1`.
@@ -2495,11 +2612,38 @@ dbcli shell --sql # SQL-only mode
2495
2612
  Inside the shell:
2496
2613
  - Type SQL statements ending with `;` to execute
2497
2614
  - Type dbcli commands without the `dbcli` prefix (e.g., `schema users`)
2615
+ - Prefix a subcommand with `\` to force it: `\delete users --where id=1`. Required for the
2616
+ subcommands whose names are also SQL keywords — `insert`, `update`, `delete`, `explain` —
2617
+ because a line starting with one of those is read as SQL,
2618
+ which is what a shell is for. The prefix works on every subcommand, so it is one rule
2619
+ rather than a list of exceptions (#88).
2620
+ - A subcommand runs in its own process with no stdin, so its ordinary y/n confirmation reads
2621
+ EOF and cancels. `\insert` / `\update` / `\delete` therefore need `--force` from inside
2622
+ the shell — or type the SQL at the prompt, where the confirmation can actually be
2623
+ answered. Tier two is unaffected either way: it is refused in the child and points you
2624
+ back to the prompt.
2498
2625
  - Use Tab for auto-completion (SQL keywords, table names, column names)
2499
2626
  - Type `.help` for meta commands (.quit, .clear, .format, .history, .timing)
2500
- - Multi-line SQL: keeps accumulating until `;` is found
2627
+ - Multi-line SQL: keeps accumulating until `;` is found. Meta commands still work while a
2628
+ statement is accumulating — `.quit` quits, `.clear` abandons the buffer — and Ctrl-C
2629
+ cancels. A line that reads as SQL but names a subcommand and carries a double-dash option prints
2630
+ a note saying which prefix would have reached the subcommand.
2501
2631
  - History persists across sessions (~/.dbcli_history)
2502
2632
 
2633
+ > **Tier two applies here (2.0.0)** — on SQL connections a typed `UPDATE` / `DELETE` with
2634
+ > no `WHERE`, a `DROP` or a `TRUNCATE` asks for the target table name before it runs.
2635
+ > Anything else typed cancels the statement and returns to the prompt; the session stays
2636
+ > open, and Ctrl-C withdraws the question itself — the next line is read as a statement,
2637
+ > not as the answer. Tier one (the y/N on ordinary writes) is deliberately not wired here — every line
2638
+ > is typed by a person. Piped input (`dbcli shell < script.sql`) has nobody to answer, so a
2639
+ > tier-two statement is refused and the remaining lines still run. Redis, MongoDB and
2640
+ > Elasticsearch shells are unaffected.
2641
+ >
2642
+ > dbcli **subcommands** typed in the shell (`query "..."`, `delete ...`) run as separate
2643
+ > processes with no stdin, so they cannot ask anything: a tier-two statement there is
2644
+ > refused with a message pointing you back to the `dbcli>` prompt, where the confirmation
2645
+ > can actually be typed.
2646
+
2503
2647
  The REPL flavor depends on the active engine: SQL engines and MongoDB use the
2504
2648
  form above; **Redis** opens a single-line command REPL (see [Redis › Interactive
2505
2649
  shell](#interactive-shell)); **Elasticsearch** opens a Kibana Dev Tools-style