fix(redis): skip confirmation for regular writes

This commit is contained in:
t8y2 committed 2026-07-09 18:59:12 +08:00
1 parent 8c8c48f9b1
commit 39d956f2e8
20 files changed
+283 -84

No files matched your search

@@ -151,6 +151,11 @@ const dangerConfirmLabel = computed(() => {
if (pendingDanger.value?.kind === "command") return t("dangerDialog.confirm");
return t("dangerDialog.deleteConfirm");
});
const dangerMessage = computed(() => {
// Redis write commands such as SET/HSET are mutating but not necessarily delete operations.
if (pendingDanger.value?.kind === "command") return t("dangerDialog.redisCommandMessage");
return t("dangerDialog.deleteMessage");
});
const commandPrompt = computed(() => `db${commandDb.value}>`);
const createKeyTypeOptions = computed<{ value: RedisCreateKeyType; label: string }[]>(() => [
{ value: "string", label: "String" },
@@ -518,12 +523,11 @@ async function runRedisCommand(command: string) {
// The db this command ran on — capture before nextRedisCommandDb() advances it.
const executedDb = commandDb.value;
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
if (result.safety === "confirm") {
await loadKeys();
}
// Drop the cached key-name completion for this db so the editor's autocomplete
// reflects keys added/removed/renamed by SET/DEL/RENAME/...
if (isRedisMutatingCommand(command)) {
const mutatesKeys = isRedisMutatingCommand(command);
if (mutatesKeys) {
await loadKeys();
connectionStore.invalidateCompletionCache(props.connectionId, String(executedDb));
// Refresh the sidebar db key counts (INFO keyspace) so `dbN (count)` stays accurate
// after the write. Fire-and-forget so the terminal stays responsive.
@@ -1224,7 +1228,7 @@ defineExpose({ focusSearch });
</Pane>
</Splitpanes>
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="t('dangerDialog.deleteMessage')" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
<DangerConfirmDialog v-model:open="showDangerConfirm" :message="dangerMessage" :details="dangerDetails" :confirm-label="dangerConfirmLabel" @confirm="applyDangerAction" />
<Dialog v-model:open="showCreateKeyDialog">
<DialogContent class="sm:max-w-md" :style="editorFontFamilyStyle">
+1
View File
@@ -2223,6 +2223,7 @@ export default {
dangerDialog: {
title: "Dangerous Operation",
message: "This SQL statement may modify or delete data irreversibly. Are you sure you want to execute it?",
redisCommandMessage: "This Redis command may modify data and cannot be undone automatically. Continue?",
suppressFuturePrompts: "Do not ask again for dangerous SQL",
wrapLines: "Toggle word wrap",
deleteMessage: "This delete operation may be irreversible. Continue?",
+1
View File
@@ -2155,6 +2155,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "Operación peligrosa",
message: "Esta sentencia SQL puede modificar o eliminar datos de forma irreversible. ¿Estás seguro de que deseas ejecutarla?",
redisCommandMessage: "Este comando de Redis puede modificar datos y no se puede deshacer automáticamente. ¿Continuar?",
suppressFuturePrompts: "No volver a preguntar para SQL peligroso",
wrapLines: "Alternar ajuste de línea",
deleteMessage: "Esta operación de eliminación puede ser irreversible. ¿Continuar?",
+1
View File
@@ -2153,6 +2153,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "Operazione Pericolosa",
message: "Questa istruzione SQL potrebbe modificare o eliminare i dati in modo irreversibile. Sei sicuro di volerla eseguire?",
redisCommandMessage: "Questo comando Redis può modificare i dati e non può essere annullato automaticamente. Continuare?",
suppressFuturePrompts: "Non chiedere più per SQL pericolosi",
wrapLines: "Attiva/disattiva ritorno a capo",
deleteMessage: "Questa operazione di eliminazione potrebbe essere irreversibile. Continuare?",
+1
View File
@@ -2153,6 +2153,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "危険な操作",
message: "このSQL文はデータを不可逆的に変更または削除する可能性があります。実行してもよろしいですか?",
redisCommandMessage: "このRedisコマンドはデータを変更し、自動的に元に戻せない可能性があります。続行しますか?",
suppressFuturePrompts: "危険なSQLの確認を今後表示しない",
wrapLines: "折り返し表示を切り替え",
deleteMessage: "この削除操作は元に戻せない可能性があります。続行しますか?",
+1
View File
@@ -2154,6 +2154,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "Operação perigosa",
message: "Esta instrução SQL pode modificar ou excluir dados de forma irreversível. Tem certeza de que deseja executá-la?",
redisCommandMessage: "Este comando Redis pode modificar dados e não pode ser desfeito automaticamente. Continuar?",
suppressFuturePrompts: "Não perguntar novamente para SQL perigoso",
wrapLines: "Alternar quebra de linha",
deleteMessage: "Esta operação de exclusão pode ser irreversível. Continuar?",
+1
View File
@@ -2223,6 +2223,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "危险操作",
message: "此 SQL 语句可能不可逆地修改或删除数据,确认要执行吗?",
redisCommandMessage: "此 Redis 命令可能会修改数据,且无法自动撤销,确认要继续吗?",
suppressFuturePrompts: "以后执行危险 SQL 不再提示",
wrapLines: "切换自动换行",
deleteMessage: "此删除操作可能不可逆,确认要继续吗?",
+1
View File
@@ -2010,6 +2010,7 @@ export default withEnglishFallback({
dangerDialog: {
title: "危險操作",
message: "此 SQL 語句可能不可逆地修改或刪除資料,確認要執行嗎?",
redisCommandMessage: "此 Redis 命令可能會修改資料,且無法自動復原,確認要繼續嗎?",
suppressFuturePrompts: "之後執行危險 SQL 不再提示",
wrapLines: "切換自動換行",
deleteMessage: "此刪除操作可能不可逆,確認要繼續嗎?",
+1 -1
View File
@@ -1391,7 +1391,7 @@ export interface RedisScanResult {
total_keys: number;
}
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
export type RedisCommandSafety = "allowed" | "write" | "confirm" | "blocked";
export interface RedisCommandResult {
command: string;
@@ -1,4 +1,4 @@
export type RedisCommandSafety = "allowed" | "confirm" | "blocked";
export type RedisCommandSafety = "allowed" | "write" | "confirm" | "blocked";
const BLOCKED_COMMANDS = new Set(["KEYS", "FLUSHALL", "SHUTDOWN", "CONFIG", "SAVE", "BGSAVE", "SLAVEOF", "REPLICAOF", "MIGRATE", "MODULE", "SCRIPT", "EVAL", "EVALSHA"]);
@@ -9,30 +9,84 @@ const CONFIRM_COMMANDS = new Set([
"EXPIREAT",
"PEXPIRE",
"PEXPIREAT",
"PERSIST",
"RENAME",
"RENAMENX",
"GETDEL",
"HDEL",
"LPOP",
"RPOP",
"LREM",
"LTRIM",
"SPOP",
"SREM",
"ZREM",
"ZPOPMAX",
"ZPOPMIN",
"ZMPOP",
"ZREMRANGEBYLEX",
"ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE",
"XDEL",
"XTRIM",
"MOVE",
"SORT",
"SDIFFSTORE",
"SINTERSTORE",
"SUNIONSTORE",
"ZDIFFSTORE",
"ZINTERSTORE",
"ZRANGESTORE",
"ZUNIONSTORE",
"PFMERGE",
"GEOSEARCHSTORE",
"FLUSHDB",
]);
const WRITE_COMMANDS = new Set([
"APPEND",
"BITFIELD",
"BITOP",
"COPY",
"DECR",
"DECRBY",
"GEOADD",
"GEORADIUS",
"GEORADIUSBYMEMBER",
"GETSET",
"INCR",
"INCRBY",
"INCRBYFLOAT",
"SET",
"SETEX",
"PSETEX",
"SETNX",
"SETRANGE",
"MSET",
"MSETNX",
"PERSIST",
"HSET",
"HDEL",
"LPUSH",
"RPUSH",
"LPOP",
"RPOP",
"HMSET",
"HINCRBY",
"HINCRBYFLOAT",
"HSETNX",
"LINSERT",
"LSET",
"LREM",
"LMOVE",
"LPUSH",
"LPUSHX",
"PFADD",
"RPUSH",
"RPUSHX",
"RESTORE",
"SADD",
"SREM",
"ZADD",
"ZREM",
"ZINCRBY",
"SETBIT",
"XADD",
"XDEL",
"FLUSHDB",
"XACK",
"XAUTOCLAIM",
"XCLAIM",
"XSETID",
]);
export function firstRedisCommandToken(command: string): string {
@@ -64,5 +118,6 @@ export function classifyRedisCommandSafety(command: string): RedisCommandSafety
const token = firstRedisCommandToken(command);
if (BLOCKED_COMMANDS.has(token)) return "blocked";
if (CONFIRM_COMMANDS.has(token)) return "confirm";
if (WRITE_COMMANDS.has(token)) return "write";
return "allowed";
}
@@ -2,15 +2,17 @@
* Static Redis command metadata, distilled from the official Redis command table
* (redis-doc `commands.json` / the `COMMAND` output). Kept offline and lightweight:
* we only retain what the editor syntax diagnostics need — `arity` and `group` —
* plus a `safety` hint for danger/write-command highlighting.
* plus a `safety` hint for command gating and write-command highlighting.
*
* Arity semantics (matches the Redis `COMMAND` reply, command name INCLUDED in the count):
* arity > 0 → the command takes exactly `arity` tokens (e.g. GET key → arity 2).
* arity < 0 → the command takes AT LEAST `-arity` tokens (e.g. MSET k v [k v ...] → arity -3).
*
* `safety` is aligned with the token-level sets in `redisCommandSafety.ts` (used to gate
* execution). Here it is recorded per-command (including subcommands) so diagnostics can
* be more precise than the first-token classification.
* execution). `write` commands mutate data without a confirmation prompt; `confirm`
* commands are destructive/structural enough to ask first. Here it is recorded
* per-command (including subcommands) so diagnostics can be more precise than the
* first-token classification.
*
* Subcommands are keyed as `"MAIN SUB"` in UPPER CASE (e.g. `"CONFIG GET"`, `"XGROUP CREATE"`).
*/
@@ -27,6 +29,57 @@ export interface RedisCommandSpec {
type Spec = [arity: number, group: string, safety?: RedisCommandSafety];
const WRITE_WITHOUT_CONFIRM = new Set([
"APPEND",
"BITFIELD",
"BITOP",
"CLIENT SETINFO",
"CLIENT SETNAME",
"COPY",
"DECR",
"DECRBY",
"GEOADD",
"GEORADIUS",
"GEORADIUSBYMEMBER",
"GETSET",
"HINCRBY",
"HINCRBYFLOAT",
"HMSET",
"HSET",
"HSETNX",
"INCR",
"INCRBY",
"INCRBYFLOAT",
"LINSERT",
"LMOVE",
"LPUSH",
"LPUSHX",
"MSET",
"MSETNX",
"PERSIST",
"PFADD",
"PSETEX",
"RESTORE",
"RPUSH",
"RPUSHX",
"SADD",
"SET",
"SETBIT",
"SETEX",
"SETNX",
"SETRANGE",
"XACK",
"XADD",
"XAUTOCLAIM",
"XCLAIM",
"XGROUP CREATE",
"XGROUP CREATECONSUMER",
"XGROUP SETID",
"XSETID",
"ZADD",
"ZINCRBY",
]);
// Compact tuple form → expanded into the record below.
const RAW_COMMANDS: Record<string, Spec> = {
// ---- String ----
@@ -367,7 +420,12 @@ const RAW_COMMANDS: Record<string, Spec> = {
READWRITE: [1, "cluster"],
};
export const REDIS_COMMAND_TABLE: Record<string, RedisCommandSpec> = Object.fromEntries(Object.entries(RAW_COMMANDS).map(([name, [arity, group, safety]]) => [name.toUpperCase(), { arity, group, safety: safety ?? "allowed" }]));
function commandTableSafety(name: string, safety?: RedisCommandSafety): RedisCommandSafety {
if (safety === "confirm" && WRITE_WITHOUT_CONFIRM.has(name.toUpperCase())) return "write";
return safety ?? "allowed";
}
export const REDIS_COMMAND_TABLE: Record<string, RedisCommandSpec> = Object.fromEntries(Object.entries(RAW_COMMANDS).map(([name, [arity, group, safety]]) => [name.toUpperCase(), { arity, group, safety: commandTableSafety(name, safety) }]));
/**
* Resolve a command spec. Handles two-token subcommands (e.g. `XGROUP CREATE`,
@@ -442,7 +500,7 @@ const NON_MUTATING_BLOCKED = new Set(["KEYS", "BGSAVE", "SAVE", "SHUTDOWN", "REP
* completion for that db is potentially stale and should be dropped.
*
* Mapping from the diagnostic `safety` field:
* - `confirm` → every write command mutates keys (SET/DEL/INCR/HSET/...).
* - `write`/`confirm` → every write command mutates keys (SET/DEL/INCR/HSET/...).
* - `blocked` → mostly destructive/admin; we keep the ones that may touch keys
* (FLUSHALL, MIGRATE, EVAL[ESHA]) and exclude the read-only/admin
* ones in `NON_MUTATING_BLOCKED`.
@@ -455,7 +513,7 @@ export function isRedisMutatingCommand(command: string): boolean {
const argv = firstRedisArgvUpper(command);
const spec = resolveRedisCommandSpec(argv);
if (!spec) return false;
if (spec.safety === "confirm") return true;
if (spec.safety === "write" || spec.safety === "confirm") return true;
if (spec.safety === "blocked") {
return !NON_MUTATING_BLOCKED.has(argv[0]);
}
@@ -196,7 +196,7 @@ export function buildRedisSyntaxDiagnostics(source: string): SqlSemanticDiagnost
} else if (spec.safety === "confirm") {
diagnostics.push({
span: lineSpan(lineNo, commandToken.startColumn, commandToken.endColumn),
message: `Write command '${argv[0].value}' — will modify data`,
message: `Dangerous command '${argv[0].value}' — confirmation recommended`,
severity: "warning",
});
}