Repository navigation
v1.58.0 - A write that did not happen stops reporting success
Changed
-
A
migrateaction somebody declined at the prompt reportedsuccess, and the prompt itself came from inside core.DDLExecutorcalledpromptUser.confirmdirectly — the one thing ADR 0009 removed fromDataExecutor, still in place here because the CI gate reads writes and that was an import — and a declinedDROP TABLEcame back asstatus: "success"with the cancellation mentioned only inwarnings, so a caller readingstatuswas told the table was gone.DDLExecutionOptionsnow carries the sameconfirmcallback,src/commands/mutation-confirm.tssupplies the CLI's implementation on stderr like every other question dbcli asks, andDDLExecutionResult.statusgainedcancelled. A destructive operation with no handler and no--forceis refused rather than defaulted either way, matchingDataExecutor. With the import gone,scripts/check-core-no-stdout.tsnow rejects an@/utils/promptsimport anywhere undersrc/core/**— no module had to join the ratchet for that rule to hold, which is the evidenceddl-executorwas the last one. ADR 0009 recorded this as an open gap one commit ago and now records it as closed, with the falsification condition naming the new rule (#70). -
MongoDB and Redis writes now ask before writing, and this breaks unattended scripts that never passed
--force.DataExecutorperforms the confirmation, and only SQL goes through it:insert,updateanddeleteagainst MongoDB or Redis were issued straight from the command to the adapter, sodbcli delete orders --where '{}'emptied a collection without asking anybody, in a terminal or out of it — while the same command against PostgreSQL stopped and waited. All six branches now pass throughconfirmDirectMutationbefore an adapter is even built, with--forcehonoured in exactly that one place, so the three engines cannot answer the question differently. The prompt shows the statement the dry run would print and omits the parameter block, because a MongoDB or Redis statement carries its values inline. The consequence to plan for: dbcli's existing rule is that a non-interactive run cannot answer the prompt and therefore ends asstatus: "cancelled"having changed nothing, which is what SQL automation has always had to pass--forceto avoid — MongoDB and Redis automation must now do the same, and a script that does not will stop writing rather than start failing silently.cancelledis consequently reachable on every engine, which is what the documentation already implied.tests/unit/commands/mongo-redis-confirmation.test.tscovers all six branches against a mock adapter, andtests/integration/mongo-redis-confirmation.test.tsre-asserts the important half against the real servers indocker-compose.test.yml— the document is still there, the key still holds its value — because "the adapter method was not called" is a statement about a mock, not about the data. That compose file gained amongodbservice on the default port, which the pre-existing mongo integration tests were already assuming and silently skipping without, andbun run test:dockernow runs all oftests/integrationrather than onlytests/integration/adapters— it brought up six services and then exercised three of them (#70). -
statuson theinsert/update/deleteresult envelope gainedcancelledanddry_run, and this is a breaking change to what those commands report. Declining at the confirmation prompt, previewing with--dry-run, and running a write that matched no rows were all one value:status: "success"withrows_affected: 0. The three are different events and a caller had no way to tell them apart. Worse, all three commands derived the audit entry's success flag from that field, so pressingNat the prompt wrote an audit record saying the write had happened — an audit log that lies is worse than none. A parallel field was rejected: it would have leftstatusstill saying success whileoutcomesaid cancelled, and every consumer reading onlystatus—writeAuditEntryincluded — would have stayed wrong. Exit codes are unchanged: onlyerrorexits1, because cancelling is a choice rather than a failure. The audit mapping now lives in one place (src/commands/mutation-audit.ts) instead of one copy per command; a dry run is recorded as successful because it did complete the preview it was asked for, a cancellation is recorded as unsuccessful, andmetadata.outcomecarries the exact ending in both cases, since a boolean cannot express "did not happen, did not fail".tests/unit/core/data-executor-outcome-status.test.tspins all four endings as mutually distinguishable and asserts that neither cancellation nor dry run issues the statement. The guarantee is that no write is sent, not that the database is untouched: both paths still open a connection and read the table schema, because the SQL a dry run exists to show cannot be built without the column list, and a confirmation prompt has nothing to display until it is.tests/unit/commands/mutation-db-contact.test.tsstates that boundary at the command level —executenever called,connectandgetTableSchemacalled once each — so the wider claim cannot be assumed from the narrower test (#70). -
The confirmation prompt for
insert/update/deletemoved to stderr, so stdout is one JSON document whether or not the write was forced. The generated SQL, the destructive-delete warning, the parameter list, and they/nquestion were all written to stdout ahead of the result envelope, which meant that every mutation nobody passed--forceto produced stdout no parser could read — including with--format json, whose entire purpose is to be parsed. The bug survived because the JSON test forced the write and therefore skipped the confirmation altogether. All four now go to stderr, matching whataudit clearalready did for its own confirmation: a question addressed to a person is not part of the result. A terminal shows both streams, so nothing changes for the human being asked, andtests/unit/commands/mutation-output-characterisation.test.tspins the block byte for byte on stderr while assertingJSON.parsesucceeds on stdout for a non-forced run.promptUser.confirmwrites to stderr for every caller now, not only these three, since a y/n question on the data channel is never what was wanted. Everything in the block is localised:Generated SQL:andParameters:are newceremony.*keys, and the destructive-delete warning and the question itself moved out ofDataExecutor—MutationConfirmationRequestnow carriesdestructive: booleaninstead of a finishedwarningsentence andpromptstring. The line is not "core cannot translate" —permission-guardnow does, andblacklist-validatoralways did — it is that core states facts and the command layer chooses words: whether a delete can be undone is a fact, while the sentence shown about it, in what tone and on which stream, is presentation, and an embedder should be able to say it in its own product's voice. The refusalenforcePermissionForTypethrows is localised in place (errors.permission_requires_level), with the permission names interpolated verbatim since they are the values written in the config file. English output is unchanged in every case;tests/unit/i18n/ceremony-messages.test.tschecks key and placeholder parity between the twoceremony.jsonfiles, so an English string added without its translation now fails (#70). -
--recoverynow covers failures the executor reports, not only failures it throws, and the recovery envelope replaces the result envelope rather than following it. A statement that failed insideDataExecutorreturned a result withstatus: "error", which the command printed as the JSON result envelope before exiting1—--recoverywas consulted only in the outer catch block, so the flag silently did nothing for the most common kind of write failure. Both failure paths now emit the recovery envelope, and a caller parsing stdout gets exactly one JSON document either way. Automation that parsed the result envelope out of a failed--recoveryrun must read the recovery envelope instead; automation that does not pass--recoveryis unaffected (#70).
Added
-
insert,updateanddeletesay what happened in prose when a person is watching. These commands declared--formatbut never branched on it, so a human running one by hand got a JSON envelope and nothing else. In an interactive terminal they now print the affected row count and table, the elapsed time for work that actually ran, and — for a write that changed rows — the fact that dbcli has no automatic undo for it. Redirected or piped stdout is byte-for-byte the envelope it always was, and--format jsonkeeps the envelope in a terminal too;--format textis deliberately not treated as a request for prose, since it is the flag's default and therefore appears on invocations that asked for nothing. Ceremony strings live inresources/lang/{en,zh-TW}/ceremony.json, following the precedent set by the shell strings rather than being merged into the general message file (#70). -
A write that fails in a terminal now says so in prose too, on stderr. Prose covered the endings a person cares least about — a successful write, a cancellation, a dry run — while the two that actually stop you got a raw JSON envelope on stdout: a blacklist refusal and a validation failure (a malformed
--set, a missing--where). Both now go throughprintMutationFailure, which prints the reason as a sentence when somebody is watching and the same envelope, byte for byte, when nobody is. Human-mode failures — including the executor's ownstatus: "error"— write to stderr, joining thePermissionErrorandConnectionErrorbranches that always did, so every human-facing failure is on the error stream while routine progress stays on stdout. A blacklist refusal gets a hint namingdbcli blacklist list; nothing else gets the--recoveryline, because--recoverywrites a plan for a statement that failed against the database and a refusal at this stage never reached one (#70). -
Core modules can no longer write to stdout, and CI fails if one starts.
DataExecutorprinted the generated SQL and blocked onpromptUser.confirmfrom insidesrc/core, on the same stdout that carries the JSON envelope agents parse — anddist/core.mjsshipped a realimport("@inquirer/prompts")so that a library consumer could be handed an interactive prompt it never asked for. Core now describes the pending mutation and asks the caller through aconfirmcallback;src/commands/mutation-confirm.tsholds the CLI's implementation.scripts/check-core-no-stdout.tsenforces the boundary in CI, with the 16 modules that predate the rule in a ratchet list that can only shrink. The reasoning, its alternatives, and the condition that would falsify it are recorded indocs/adr/0009-core-does-not-write-to-stdout.md(#70).
Fixed
-
Deleting an Elasticsearch index needed
data-admin, and three schema changes needed nothing butquery-only. The classifier matched paths without looking at the method, so one function mis-tiered four different requests.DELETE /usersremoves an entire index and was classifiedDELETE— the delete-a-document tier — which meantdata-admincould destroy an index while the SQL equivalent,DROP TABLE, has always requiredadmin;DELETE /logs-*andDELETE /_allwent the same way, at cluster scale. Worse,DELETE /users/_alias/amatched the_aliasread rule andPUT /users/_mappingandPUT /users/_settingsmatched theirs, so aquery-onlycredential could delete an alias and rewrite a mapping. A DELETE is now theDELETEtier only when it names a document (_doc/<id>or_source/<id>) andDROP—admin— otherwise, and a read rule requires a read method, with_searchand_countalso acceptingPOSTbecause that is how a query with a body is sent. Anything whose scope cannot be established fails closed toadmin: the cost of being wrong that way is a refusal a user can escalate, rather than an index nobody can get back. The adapter's own calls are unaffected — it reads mappings withGETand deletes documents through_doc/<id>— so this changes only what a raw request throughquerymay do.tests/unit/core/elasticsearch-destructive-scope.test.tspins all seventeen cases as a matrix of classification and tier (#70). -
The last four integration files that never ran anywhere now run everywhere.
p1-error-classification,p2-explainandp3-missing-indexwere gated onTEST_MARIADB_HOST— an operator-supplied MariaDB thatdocker-compose.test.ymldid not provide, so 23 assertions about MariaDB error codes,ANALYZE SELECT, and EXPLAIN output shape skipped on every machine and every CI run since they were written. The compose file gained amariadb:11service on port 3308 (its own service, not a MySQL alias: the codes and EXPLAIN shape are precisely what those tests distinguish), and the three files now gate on reachability like everything else.q-livewas gated the same way onDBCLI_LIVE_PG_HOST, and wanted nothing more specific than a real PostgreSQL, which the stack already ships. Running it for the first time falsified one of its assertions: it expectedSELECT :v AS xto return the number1, and PostgreSQL types an untyped parameter as text —EXPLAIN VERBOSEshows'1'::text— so the value is the string"1". The assertion was wrong from the day it was written and no run had ever said so.tests/integrationnow reports 652 passing and 0 skipped (#70). -
q,queryand the Elasticsearch path told users to grant a level that would not have helped.PermissionError.requiredPermissionis what every command interpolates intoPermission denied (required: …), and these three throw sites passed the level the caller already had — so a query-only user runningDELETEthroughqreadrequired: query-onlyabove a sentence saying it needs data-admin. The structured-write path was corrected first (#72) by deriving the level at the throw site, which works only where the type alone decides it; a write hidden inside a read needs admin whatever its leading keyword says, a multi-statement SQL needs admin, and an unrecognised statement needs read-write. The level is now decided by the branch that decides the refusal and carried onPermissionCheckResult.requiredPermission, so the header and the reason cannot disagree —tests/unit/core/permission-refusal-level.test.tsasserts that granting exactly what a refusal names lets the same statement through, rather than pinning a table of strings. Elasticsearch stopped restating the tier table while it was being fixed: it was a third copy, agreeing with the shared one on every type its classifier produces, and its refusal now names the level instead of saying "requires higher permission tier", which is true of every refusal and tells nobody what to change (errors.elasticsearch_requires_level, both languages) (#70). -
The integration tests never ran in CI, and several of them never ran anywhere. The matrix job sets
SKIP_INTEGRATION_TESTS=true, sotests/integrationwas compiled and skipped on every push — 639 assertions against MySQL, PostgreSQL, Redis, MongoDB and Elasticsearch that only ever executed on a developer's machine. A newintegrationjob runs them on Linux againstdocker-compose.test.yml. The part that makes it a signal rather than a formality:REQUIRE_INTEGRATION_SERVICES=trueturns the suite's auto-skip into a failure naming the address, because a job that starts no services otherwise reports exactly the same green as one that starts all of them, andbun run services:checkfails first with a readable list, reading the ports out of the compose file rather than a copy that would drift. Turning that flag on immediately found the second half of the bug:verify-migration,verify-rollback,verify-safe-backfillandassert-verification-artifactdialledlocalhost:5432aspostgres/postgres— a server this repo has never shipped — while the adapter tests usedPG_PORTat5433asdbcli/testpass, the one in the compose file. Two spellings for one address, and the files using the wrong one had been skipping since they were written, locally included. Connection defaults now live once intests/integration/helpers.ts. (#70). -
insert/update/deleteaccepted any--formatvalue and quietly did something else with it. The flag is declared astext or json, but nothing checked, andshouldRenderForHumanonly special-casesjson— so--format xmlmeant "prose in a terminal, envelope in a pipe", which is the default it was trying to override. An unsupported value is now refused before the connection, the schema read, and the audit write, the waydbcli exporthas always validated its own formats, with the offending value named and localised (errors.invalid_output_format).--planis covered by the same guard (#70). -
Four refusals reached a zh-TW user as English, or as English glued to Chinese. The three sentences
permission-guardbuilds for the cases the tier table cannot phrase — a write hidden inside a read, a multi-statement SQL below admin, and an unrecognised statement under query-only — were string literals, andhandleMutationErrorprefixed the already-translatedPermissionErrormessage with a literalPermission denied:, producing a half-translated sentence. All four are catalogue keys now (errors.escalated_write_requires_admin,errors.multiple_statements_refused,errors.unknown_statement_query_only,errors.permission_denied_reason), with the permission levels and SQL keywords interpolated verbatim because those are values a user types into a config file. The English is character-for-character what it was — several tests assert these sentences and none of them changed — andtests/unit/i18n/permission-refusal-messages.test.tsrenders each in both languages and checks key and placeholder parity across the twomessages.jsonfiles, so an English string added without its translation fails (#70). -
An Elasticsearch
insert/update/deleteanswered every user in Traditional Chinese. The "Elasticsearch does not support this command" sentence was a string literal in theerrorfield of the envelope, in three files where everything else goes throught(). It is now{insert,update,delete}.elasticsearch_unsupportedwith English and zh-TW values, so the message followsDBCLI_LANGlike the rest of the CLI.tests/unit/commands/redis-es-unsupported.test.tsasserts the English text under the default locale, which is what an English-locale user was never getting (#70). -
The three write paths' permission checks agreed by luck, and their refusal messages named levels that would not have worked.
executeInsertandexecuteUpdatehanded the classifier a synthetic statement ('INSERT INTO dummy') whileexecuteDeletecomparedthis.permissioninline — one axis, two implementations, certain to drift. All three now callenforcePermissionForType, which skips the classifier because the caller assembled the statement and therefore already knows its type; passing the real generated SQL was tried and rejected, since it forces the statement to be built and its columns validated before the caller is known to be authorised, so an unauthorised user would learnColumn not foundfirst and the schema would leak. Separately,handleMutationErrordiscarded thePermissionErrorand substituted a fixed sentence about query-only mode whatever the actual level was, and excludeddeleteoutright. Refusals are now derived from the same tier table the decision uses, so they name the lowest level that actually permits the operation alongside the current one — previously a query-only user was toldDELETE"requires read-write", which read-write does not grant. The header printed above that reason was wrong in the same way and is fixed with it:PermissionError.requiredPermissionis what every command interpolates intoPermission denied (required: …), and the SQL path passed the level the caller already had, so a query-only user readrequired: query-onlydirectly above a sentence saying INSERT requires read-write. It now carries the level that would actually work, which is what the Redis enforcer has always passed and whatdeleteused to hardcode.tests/unit/core/permission-refusal-level.test.tsasserts the named level really permits the operation rather than pinning a table of strings.qand the Elasticsearch enforcer still pass the current level; those refusals cover composite and hidden-write cases where "the level that would work" is not always a single answer, and they are left for a change that can decide it. The verdict matrix itself is unchanged:tests/unit/core/data-executor-permission-characterisation.test.tspinned twelve verdicts before the unification landed, and only four messages moved.permission-guard.tswas 1187 lines by the end of this and is now 460: SQL lexical analysis, Redis, and Elasticsearch are three independent classification domains and moved tosrc/core/permission/{sql-analysis,redis,elasticsearch}.tswith no re-export shim — the six importers point at the new paths, and none of the moved symbols was on the published./coresurface. The three tier branches insidecheckPermissionForClassificationalso stopped repeating whatTIER_GRANTSalready says: each tier's permitted set is derived by accumulating the tiers below it, which is the same drift-by-duplication this bullet removed from the refusal messages (#70).
Install: npm install -g @carllee1983/dbcli@1.58.0
Full diff: v1.57.0...v1.58.0