@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.
- package/.cursor/rules/dbcli.mdc +19 -0
- package/.cursor/skills/dbcli/reference.md +146 -2
- package/.github/skills/dbcli/SKILL.md +19 -0
- package/.github/skills/dbcli/reference.md +146 -2
- package/CHANGELOG.md +77 -0
- package/assets/SKILL.md +19 -0
- package/assets/SKILL.zh-TW.md +17 -0
- package/assets/reference.md +146 -2
- package/dist/cli-runtime.mjs +1134 -227
- package/dist/cli.mjs +2 -1
- package/dist/core.mjs +68 -2
- package/package.json +2 -1
- package/plugins/dbcli-agent/skills/dbcli/SKILL.md +19 -0
- package/plugins/dbcli-agent/skills/dbcli/reference.md +146 -2
- package/skills/dbcli/SKILL.md +19 -0
- package/skills/dbcli/reference.md +146 -2
package/dist/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// package.json
|
|
4
4
|
var package_default = {
|
|
5
5
|
name: "@carllee1983/dbcli",
|
|
6
|
-
version: "1.
|
|
6
|
+
version: "2.1.0",
|
|
7
7
|
description: "Database CLI for AI agents",
|
|
8
8
|
type: "module",
|
|
9
9
|
publishConfig: {
|
|
@@ -86,6 +86,7 @@ var package_default = {
|
|
|
86
86
|
"agent-core:check": "bun run scripts/check-agent-core-purity.ts",
|
|
87
87
|
"core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
|
|
88
88
|
typecheck: "tsc --noEmit --pretty false",
|
|
89
|
+
"typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
|
|
89
90
|
"test:perf": "bun test ./tests/perf/*.bench.ts",
|
|
90
91
|
lint: "eslint src tests scripts --ext .ts --max-warnings=0",
|
|
91
92
|
"format:check": "prettier --check .",
|
package/dist/core.mjs
CHANGED
|
@@ -1637,6 +1637,15 @@ var messages_default = {
|
|
|
1637
1637
|
health: {
|
|
1638
1638
|
description: "Show AuditLogger health snapshot (size, entries, lock state, last write)"
|
|
1639
1639
|
},
|
|
1640
|
+
write_gate: {
|
|
1641
|
+
description: "Summarize tier-two write-gate decisions (ADR 0010's measurement)",
|
|
1642
|
+
header: "Write-gate summary \u2014 connection: {conn}",
|
|
1643
|
+
window: "Audit entries scanned: {scanned}, {from} \u2192 {to}",
|
|
1644
|
+
decisions: "Tier-two evaluations: {count}, {from} \u2192 {to}",
|
|
1645
|
+
tier_one: "Tier one, counted apart: {count} ({outcomes})",
|
|
1646
|
+
no_decisions: "Tier two was never reached in this window. Per ADR 0010 that falsifies the criterion, not the gate: the shapes it looks for are not the shapes people write.",
|
|
1647
|
+
no_entries: "The audit log is empty, so this says nothing about the gate either way. Run some writes, or check `dbcli audit health`."
|
|
1648
|
+
},
|
|
1640
1649
|
disabled_hint: "Audit is disabled (audit.enabled = false in .dbcli). Use 'dbcli audit health' for details.",
|
|
1641
1650
|
no_entries: "No audit entries.",
|
|
1642
1651
|
show_no_match: "No audit entry matches '{prefix}'.",
|
|
@@ -1941,6 +1950,15 @@ var messages_default2 = {
|
|
|
1941
1950
|
health: {
|
|
1942
1951
|
description: "\u986F\u793A AuditLogger \u5065\u5EB7\u72C0\u614B\uFF08\u6A94\u6848\u5927\u5C0F\u3001\u7B46\u6578\u3001lock \u72C0\u614B\u3001\u6700\u5F8C\u5BEB\u5165\u7D50\u679C\uFF09"
|
|
1943
1952
|
},
|
|
1953
|
+
write_gate: {
|
|
1954
|
+
description: "\u7D71\u8A08\u7B2C\u4E8C\u7D1A\u5BEB\u5165\u9598\u9580\u7684\u5224\u5B9A\uFF08ADR 0010 \u62BC\u6CE8\u7684\u90A3\u500B\u91CF\u6E2C\uFF09",
|
|
1955
|
+
header: "\u5BEB\u5165\u9598\u9580\u6458\u8981 \u2014 \u9023\u7DDA\uFF1A{conn}",
|
|
1956
|
+
window: "\u6383\u904E\u7684\u7A3D\u6838\u7D00\u9304\uFF1A{scanned} \u7B46\uFF0C{from} \u2192 {to}",
|
|
1957
|
+
decisions: "\u7B2C\u4E8C\u7D1A\u5224\u5B9A\uFF1A{count} \u6B21\uFF0C{from} \u2192 {to}",
|
|
1958
|
+
tier_one: "\u7B2C\u4E00\u7D1A\uFF0C\u5206\u958B\u8A08\u7B97\uFF1A{count} \u6B21\uFF08{outcomes}\uFF09",
|
|
1959
|
+
no_decisions: "\u9019\u6BB5\u5340\u9593\u5167\u7B2C\u4E8C\u7D1A\u4E00\u6B21\u90FD\u6C92\u88AB\u89F8\u53CA\u3002\u4F9D ADR 0010\uFF0C\u9019\u63A8\u7FFB\u7684\u662F\u5224\u6E96\u800C\u4E0D\u662F\u9598\u9580\uFF1A\u5B83\u5728\u627E\u7684\u5F62\u72C0\u4E0D\u662F\u5927\u5BB6\u5BE6\u969B\u6703\u5BEB\u7684\u5F62\u72C0\u3002",
|
|
1960
|
+
no_entries: "\u7A3D\u6838\u7D00\u9304\u662F\u7A7A\u7684\uFF0C\u6240\u4EE5\u9019\u5C0D\u9598\u9580\u6C92\u6709\u4EFB\u4F55\u7D50\u8AD6\u3002\u5148\u8DD1\u4E00\u4E9B\u5BEB\u5165\uFF0C\u6216\u7528 `dbcli audit health` \u6AA2\u67E5\u3002"
|
|
1961
|
+
},
|
|
1944
1962
|
disabled_hint: "Audit \u5DF2\u505C\u7528\uFF08.dbcli \u4E2D audit.enabled = false\uFF09\u3002\u57F7\u884C `dbcli audit health` \u67E5\u770B\u8A73\u60C5\u3002",
|
|
1945
1963
|
no_entries: "\u6C92\u6709 audit \u7D00\u9304\u3002",
|
|
1946
1964
|
show_no_match: "\u627E\u4E0D\u5230\u7B26\u5408 '{prefix}' \u7684 audit \u7D00\u9304\u3002",
|
|
@@ -2148,7 +2166,8 @@ var shell_default = {
|
|
|
2148
2166
|
multiline_cancelled: "Multiline input cancelled.",
|
|
2149
2167
|
timing_display: "Time: {ms}ms",
|
|
2150
2168
|
rows_display: "{count} row(s)",
|
|
2151
|
-
unknown_command: "Unknown command: '{command}'. Type '.help' for available commands."
|
|
2169
|
+
unknown_command: "Unknown command: '{command}'. Type '.help' for available commands.",
|
|
2170
|
+
subcommand_prefix_hint: "This line is being read as SQL and is waiting for its `;`. If you meant the dbcli subcommand, type {command} instead. Ctrl-C cancels."
|
|
2152
2171
|
};
|
|
2153
2172
|
// resources/lang/zh-TW/shell.json
|
|
2154
2173
|
var shell_default2 = {
|
|
@@ -2170,7 +2189,8 @@ var shell_default2 = {
|
|
|
2170
2189
|
multiline_cancelled: "\u5DF2\u53D6\u6D88\u591A\u884C\u8F38\u5165\u3002",
|
|
2171
2190
|
timing_display: "\u8017\u6642\uFF1A{ms}ms",
|
|
2172
2191
|
rows_display: "{count} \u7B46\u8CC7\u6599",
|
|
2173
|
-
unknown_command: "\u672A\u77E5\u6307\u4EE4\uFF1A'{command}'\u3002\u8F38\u5165 '.help' \u67E5\u770B\u53EF\u7528\u6307\u4EE4\u3002"
|
|
2192
|
+
unknown_command: "\u672A\u77E5\u6307\u4EE4\uFF1A'{command}'\u3002\u8F38\u5165 '.help' \u67E5\u770B\u53EF\u7528\u6307\u4EE4\u3002",
|
|
2193
|
+
subcommand_prefix_hint: "\u9019\u4E00\u884C\u88AB\u7576\u6210 SQL\uFF0C\u6B63\u5728\u7B49\u5206\u865F\u3002\u5982\u679C\u4F60\u8981\u7684\u662F dbcli \u5B50\u6307\u4EE4\uFF0C\u8ACB\u6539\u6253 {command}\u3002Ctrl-C \u53EF\u53D6\u6D88\u3002"
|
|
2174
2194
|
};
|
|
2175
2195
|
// resources/lang/en/ceremony.json
|
|
2176
2196
|
var ceremony_default = {
|
|
@@ -2191,6 +2211,29 @@ var ceremony_default = {
|
|
|
2191
2211
|
confirm_destructive_warning_ddl: "\u26A0\uFE0F Warning: this schema change is destructive and cannot be undone!",
|
|
2192
2212
|
confirm_ddl_prompt: "Are you sure you want to run this {operation}? This cannot be undone.",
|
|
2193
2213
|
confirm_prompt: "Proceed with this operation?",
|
|
2214
|
+
gate_summary: "This will {operation} rows in {table}.",
|
|
2215
|
+
gate_summary_untargeted: "This will run a {operation} statement.",
|
|
2216
|
+
gate_statement: "Statement:",
|
|
2217
|
+
gate_cancelled: "Cancelled. Nothing was run.",
|
|
2218
|
+
gate_full_table_warning: "\u26A0\uFE0F This affects EVERY row in {table} and cannot be undone.",
|
|
2219
|
+
gate_ddl_warning: "\u26A0\uFE0F This destroys {table} and cannot be undone.",
|
|
2220
|
+
gate_ddl_warning_untargeted: "\u26A0\uFE0F This is a destructive schema change and cannot be undone.",
|
|
2221
|
+
gate_unparseable_warning: "\u26A0\uFE0F dbcli could not parse this statement, so it is treated as affecting everything.",
|
|
2222
|
+
gate_multiple_statements_warning: "\u26A0\uFE0F This is more than one statement, and dbcli will not judge them one at a time.",
|
|
2223
|
+
gate_multi_table_warning: "This write joins another table. How many rows of the target it touches depends on the data, not on the statement, so it cannot be checked before it runs.",
|
|
2224
|
+
gate_nested_write_warning: "\u26A0\uFE0F This statement carries a second write inside it, and dbcli will not read the tier of that write off this one.",
|
|
2225
|
+
gate_typed_prompt: "Type {phrase} to run it",
|
|
2226
|
+
gate_typed_mismatch: "That did not match {phrase}. Nothing was run.",
|
|
2227
|
+
gate_refused: "Refused: this statement is not limited to specific rows and nobody is here to confirm it (reason={reason}).",
|
|
2228
|
+
gate_refused_in_shell: "Refused: a dbcli subcommand started from the shell has no way to ask you, even though you are right here (reason={reason}).",
|
|
2229
|
+
gate_remedy_no_where: "Add a WHERE clause \u2014 WHERE 1=1 if you really do mean every row \u2014 or a LIMIT, then run it again.",
|
|
2230
|
+
gate_remedy_ddl_destruction: "No flag skips this. Run it from an interactive terminal, or apply the schema change through a reviewed migration.",
|
|
2231
|
+
gate_remedy_unparseable: "dbcli could not parse this statement and will not guess. Simplify it, or run it from an interactive terminal.",
|
|
2232
|
+
gate_remedy_multiple_statements: "Run one statement per command.",
|
|
2233
|
+
gate_remedy_multi_table: "Rewrite it as a single-table statement \u2014 move the other table into a subquery in the WHERE \u2014 or run it where someone can confirm it.",
|
|
2234
|
+
gate_remedy_nested_write: "Run the nested write as its own statement, or run this one where someone can confirm it.",
|
|
2235
|
+
gate_remedy_non_unique_where: "Match on a primary key or a uniquely indexed column, or run it from an interactive terminal.",
|
|
2236
|
+
gate_remedy_in_shell: "Type the statement at the dbcli> prompt instead \u2014 there the shell asks you to type the target table name.",
|
|
2194
2237
|
recovery_none: "This cannot be undone automatically \u2014 restore from a backup to get the previous values back.",
|
|
2195
2238
|
recovery_retry: "Re-run with --recovery to write a recovery plan, then read it with: dbcli recover",
|
|
2196
2239
|
blacklist_hint: "Run `dbcli blacklist list` to see what is protected"
|
|
@@ -2214,6 +2257,29 @@ var ceremony_default2 = {
|
|
|
2214
2257
|
confirm_destructive_warning_ddl: "\u26A0\uFE0F \u8B66\u544A\uFF1A\u9019\u500B schema \u8B8A\u66F4\u5177\u7834\u58DE\u6027\uFF0C\u57F7\u884C\u5F8C\u7121\u6CD5\u5FA9\u539F\uFF01",
|
|
2215
2258
|
confirm_ddl_prompt: "\u78BA\u5B9A\u8981\u57F7\u884C\u9019\u500B {operation} \u55CE\uFF1F\u6B64\u64CD\u4F5C\u7121\u6CD5\u5FA9\u539F\u3002",
|
|
2216
2259
|
confirm_prompt: "\u8981\u7E7C\u7E8C\u57F7\u884C\u9019\u500B\u64CD\u4F5C\u55CE\uFF1F",
|
|
2260
|
+
gate_summary: "\u9019\u53E5\u6703\u5C0D {table} \u57F7\u884C {operation}\u3002",
|
|
2261
|
+
gate_summary_untargeted: "\u9019\u53E5\u6703\u57F7\u884C {operation}\u3002",
|
|
2262
|
+
gate_statement: "\u8A9E\u53E5\uFF1A",
|
|
2263
|
+
gate_cancelled: "\u5DF2\u53D6\u6D88\uFF0C\u6C92\u6709\u57F7\u884C\u4EFB\u4F55\u8A9E\u53E5\u3002",
|
|
2264
|
+
gate_full_table_warning: "\u26A0\uFE0F \u9019\u53E5\u6703\u5F71\u97FF {table} \u7684\u6BCF\u4E00\u5217\uFF0C\u57F7\u884C\u5F8C\u7121\u6CD5\u5FA9\u539F\u3002",
|
|
2265
|
+
gate_ddl_warning: "\u26A0\uFE0F \u9019\u53E5\u6703\u6467\u6BC0 {table}\uFF0C\u57F7\u884C\u5F8C\u7121\u6CD5\u5FA9\u539F\u3002",
|
|
2266
|
+
gate_ddl_warning_untargeted: "\u26A0\uFE0F \u9019\u662F\u7834\u58DE\u6027\u7684 schema \u8B8A\u66F4\uFF0C\u57F7\u884C\u5F8C\u7121\u6CD5\u5FA9\u539F\u3002",
|
|
2267
|
+
gate_unparseable_warning: "\u26A0\uFE0F dbcli \u7121\u6CD5\u89E3\u6790\u9019\u53E5\uFF0C\u56E0\u6B64\u4E00\u5F8B\u8996\u70BA\u6703\u5F71\u97FF\u5168\u90E8\u8CC7\u6599\u3002",
|
|
2268
|
+
gate_multiple_statements_warning: "\u26A0\uFE0F \u9019\u88E1\u4E0D\u53EA\u4E00\u53E5\u8A9E\u53E5\uFF0Cdbcli \u4E0D\u6703\u9010\u53E5\u5224\u65B7\u3002",
|
|
2269
|
+
gate_multi_table_warning: "\u9019\u500B\u5BEB\u5165\u63A5\u4E86\u53E6\u4E00\u5F35\u8868\u3002\u5B83\u6703\u52D5\u5230\u76EE\u6A19\u8868\u5E7E\u5217\u53D6\u6C7A\u65BC\u8CC7\u6599\u800C\u4E0D\u662F\u8A9E\u53E5\u672C\u8EAB\uFF0C\u57F7\u884C\u524D\u7121\u6CD5\u6AA2\u67E5\u3002",
|
|
2270
|
+
gate_nested_write_warning: "\u26A0\uFE0F \u9019\u53E5\u88E1\u9762\u9084\u593E\u8457\u53E6\u4E00\u500B\u5BEB\u5165\uFF0Cdbcli \u4E0D\u6703\u62FF\u9019\u53E5\u7684\u5C64\u7D1A\u4EE3\u66FF\u90A3\u500B\u5BEB\u5165\u7684\u5C64\u7D1A\u3002",
|
|
2271
|
+
gate_typed_prompt: "\u8F38\u5165 {phrase} \u624D\u6703\u57F7\u884C",
|
|
2272
|
+
gate_typed_mismatch: "\u8F38\u5165\u7684\u5167\u5BB9\u8207 {phrase} \u4E0D\u7B26\uFF0C\u6C92\u6709\u57F7\u884C\u4EFB\u4F55\u8A9E\u53E5\u3002",
|
|
2273
|
+
gate_refused: "\u5DF2\u62D2\u7D55\uFF1A\u9019\u53E5\u6C92\u6709\u9650\u5B9A\u8981\u52D5\u54EA\u4E9B\u5217\uFF0C\u800C\u73FE\u5728\u6C92\u6709\u4EBA\u80FD\u78BA\u8A8D\uFF08reason={reason}\uFF09\u3002",
|
|
2274
|
+
gate_refused_in_shell: "\u5DF2\u62D2\u7D55\uFF1A\u5F9E shell \u555F\u52D5\u7684 dbcli \u5B50\u6307\u4EE4\u6C92\u6709\u8FA6\u6CD5\u5411\u4F60\u63D0\u554F\uFF0C\u5373\u4F7F\u4F60\u4EBA\u5C31\u5728\u9019\u88E1\uFF08reason={reason}\uFF09\u3002",
|
|
2275
|
+
gate_remedy_no_where: "\u88DC\u4E0A WHERE\uFF08\u771F\u7684\u8981\u5168\u8868\u5C31\u5BEB WHERE 1=1\uFF09\u6216 LIMIT\uFF0C\u7136\u5F8C\u91CD\u8DD1\u3002",
|
|
2276
|
+
gate_remedy_ddl_destruction: "\u6C92\u6709\u4EFB\u4F55\u65D7\u6A19\u53EF\u4EE5\u8DF3\u904E\u9019\u4E00\u95DC\u3002\u8ACB\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\uFF0C\u6216\u6539\u8D70\u7D93\u904E\u5BE9\u67E5\u7684 migration\u3002",
|
|
2277
|
+
gate_remedy_unparseable: "dbcli \u7121\u6CD5\u89E3\u6790\u9019\u53E5\uFF0C\u4E5F\u4E0D\u6703\u7528\u731C\u7684\u3002\u8ACB\u7C21\u5316\u8A9E\u53E5\uFF0C\u6216\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\u3002",
|
|
2278
|
+
gate_remedy_multiple_statements: "\u4E00\u6B21\u53EA\u57F7\u884C\u4E00\u53E5\u8A9E\u53E5\u3002",
|
|
2279
|
+
gate_remedy_multi_table: "\u6539\u5BEB\u6210\u55AE\u8868\u8A9E\u53E5\u2014\u2014\u628A\u53E6\u4E00\u5F35\u8868\u79FB\u9032 WHERE \u7684\u5B50\u67E5\u8A62\u88E1\u2014\u2014\u6216\u6539\u5728\u6709\u4EBA\u53EF\u4EE5\u78BA\u8A8D\u7684\u5730\u65B9\u57F7\u884C\u3002",
|
|
2280
|
+
gate_remedy_nested_write: "\u628A\u88E1\u9762\u90A3\u500B\u5BEB\u5165\u62C6\u6210\u7368\u7ACB\u7684\u4E00\u53E5\u4F86\u57F7\u884C\uFF0C\u6216\u6539\u5728\u6709\u4EBA\u53EF\u4EE5\u78BA\u8A8D\u7684\u5730\u65B9\u57F7\u884C\u3002",
|
|
2281
|
+
gate_remedy_non_unique_where: "\u6539\u7528\u4E3B\u9375\u6216\u552F\u4E00\u7D22\u5F15\u6B04\u4F4D\u7576\u689D\u4EF6\uFF0C\u6216\u5728\u4E92\u52D5\u7D42\u7AEF\u4E0B\u57F7\u884C\u3002",
|
|
2282
|
+
gate_remedy_in_shell: "\u8ACB\u76F4\u63A5\u5728 dbcli> \u63D0\u793A\u7B26\u6253\u9019\u53E5\u8A9E\u53E5\u2014\u2014\u5728\u90A3\u88E1 dbcli \u6703\u8ACB\u4F60\u6253\u51FA\u76EE\u6A19\u8CC7\u6599\u8868\u7684\u540D\u7A31\u4F86\u78BA\u8A8D\u3002",
|
|
2217
2283
|
recovery_none: "\u6B64\u64CD\u4F5C\u7121\u6CD5\u81EA\u52D5\u9084\u539F\uFF0C\u8981\u53D6\u56DE\u5148\u524D\u7684\u8CC7\u6599\u8ACB\u5F9E\u5099\u4EFD\u56DE\u5FA9\u3002",
|
|
2218
2284
|
recovery_retry: "\u52A0\u4E0A --recovery \u91CD\u8DD1\u53EF\u7522\u751F\u56DE\u5FA9\u8A08\u756B\uFF0C\u518D\u7528 dbcli recover \u8B80\u53D6",
|
|
2219
2285
|
blacklist_hint: "\u57F7\u884C `dbcli blacklist list` \u67E5\u770B\u53D7\u4FDD\u8B77\u7684\u9805\u76EE"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carllee1983/dbcli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Database CLI for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
"agent-core:check": "bun run scripts/check-agent-core-purity.ts",
|
|
84
84
|
"core-stdout:check": "bun run scripts/check-core-no-stdout.ts",
|
|
85
85
|
"typecheck": "tsc --noEmit --pretty false",
|
|
86
|
+
"typecheck:tests": "tsc --noEmit --pretty false -p tsconfig.tests.json",
|
|
86
87
|
"test:perf": "bun test ./tests/perf/*.bench.ts",
|
|
87
88
|
"lint": "eslint src tests scripts --ext .ts --max-warnings=0",
|
|
88
89
|
"format:check": "prettier --check .",
|
|
@@ -43,6 +43,25 @@ condition, first `query` / `export` the target rows' primary keys, then run one
|
|
|
43
43
|
`update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
|
|
44
44
|
takes a full JSON filter and is exempt.)
|
|
45
45
|
|
|
46
|
+
**Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
|
|
47
|
+
tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
|
|
48
|
+
run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
|
|
49
|
+
**Statements that are not limited to specific rows are refused outright when nobody can
|
|
50
|
+
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
|
+
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
|
+
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
+
`reason=unparseable`, `reason=multi_table`, `reason=nested_write` or
|
|
54
|
+
`reason=non_unique_where`, and **nothing
|
|
55
|
+
reaches the database**. In `dbcli shell`, a subcommand whose name is a SQL keyword needs a
|
|
56
|
+
`\` prefix (`\delete users --where id=1`) — a bare `delete …` is read as SQL. A write that joins a second table is always tier two: whether it is
|
|
57
|
+
limited to particular rows depends on the data, not on the statement. So is a statement
|
|
58
|
+
carrying a second write inside it — a data-modifying CTE
|
|
59
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`) or a `MERGE` with a
|
|
60
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` action.
|
|
61
|
+
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
62
|
+
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
63
|
+
unattended route at all; escalate to a human.
|
|
64
|
+
|
|
46
65
|
> `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
|
|
47
66
|
> `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
|
|
48
67
|
> audit-recent context or to diagnose a connection problem.
|
|
@@ -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
|
package/skills/dbcli/SKILL.md
CHANGED
|
@@ -43,6 +43,25 @@ condition, first `query` / `export` the target rows' primary keys, then run one
|
|
|
43
43
|
`update` / `delete --where "id=<pk>"` per key — or escalate to a human. (MongoDB `--where`
|
|
44
44
|
takes a full JSON filter and is exempt.)
|
|
45
45
|
|
|
46
|
+
**Write gate (2.0.0) — the rule that will refuse you.** Every write is classified into two
|
|
47
|
+
tiers. Ordinary writes (`INSERT`, `UPDATE` / `DELETE` with a `WHERE`, `CREATE`, `ALTER`)
|
|
48
|
+
run unattended exactly as before; `--yes` skips the terminal prompt a human would see.
|
|
49
|
+
**Statements that are not limited to specific rows are refused outright when nobody can
|
|
50
|
+
answer a prompt** — `UPDATE` / `DELETE` with no `WHERE`, `DROP`, `TRUNCATE`, a statement
|
|
51
|
+
the SQL parser cannot read, several statements in one string, and `update` / `delete --where` matching on no primary key and
|
|
52
|
+
no unique index. The process exits `1` with `reason=no_where`, `reason=ddl_destruction`,
|
|
53
|
+
`reason=unparseable`, `reason=multi_table`, `reason=nested_write` or
|
|
54
|
+
`reason=non_unique_where`, and **nothing
|
|
55
|
+
reaches the database**. In `dbcli shell`, a subcommand whose name is a SQL keyword needs a
|
|
56
|
+
`\` prefix (`\delete users --where id=1`) — a bare `delete …` is read as SQL. A write that joins a second table is always tier two: whether it is
|
|
57
|
+
limited to particular rows depends on the data, not on the statement. So is a statement
|
|
58
|
+
carrying a second write inside it — a data-modifying CTE
|
|
59
|
+
(`WITH x AS (DELETE FROM t RETURNING *) INSERT INTO …`) or a `MERGE` with a
|
|
60
|
+
`WHEN … THEN DELETE` / `THEN UPDATE` action.
|
|
61
|
+
**No flag bypasses this** — not `--yes`, not `--force`. To write every row on purpose, put
|
|
62
|
+
the intent in the SQL itself: add `WHERE 1=1` or a `LIMIT`. `DROP` / `TRUNCATE` have no
|
|
63
|
+
unattended route at all; escalate to a human.
|
|
64
|
+
|
|
46
65
|
> `report` and `guide` already embed an `inspect` snapshot — you do **not** need to run
|
|
47
66
|
> `dbcli inspect` first. Run `dbcli inspect --for-agent` manually only when you want the
|
|
48
67
|
> audit-recent context or to diagnose a connection problem.
|
|
@@ -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
|