From 51fa058edb77ef789478687e92199989e90a99cb Mon Sep 17 00:00:00 2001 From: onenewcode Date: Sun, 26 Jul 2026 23:33:56 +0800 Subject: [PATCH] feat(redis): add local expiration modes --- .../__tests__/DataGridClipboardRegion.spec.ts | 7 +- .../redis/RedisKeyBrowser.expiry.spec.ts | 524 ++++++++++++++++++ .../src/components/redis/RedisKeyBrowser.vue | 274 +++++++-- .../redis/RedisValueViewer.expiry.spec.ts | 311 +++++++++++ .../src/components/redis/RedisValueViewer.vue | 186 ++++++- .../DateTimePicker.dialog.spec.ts | 143 +++++ .../date-time-picker/DateTimePicker.spec.ts | 150 +++++ .../ui/date-time-picker/DateTimePicker.vue | 480 ++++++++++++++++ .../ui/date-time-picker/dateTimePicker.ts | 89 +++ .../dateTimePickerHelpers.spec.ts | 44 ++ .../components/ui/date-time-picker/index.ts | 2 + apps/desktop/src/i18n/locales/en.ts | 27 +- apps/desktop/src/i18n/locales/es.ts | 27 +- apps/desktop/src/i18n/locales/it.ts | 27 +- apps/desktop/src/i18n/locales/ja.ts | 27 +- apps/desktop/src/i18n/locales/pt-BR.ts | 27 +- apps/desktop/src/i18n/locales/zh-CN.ts | 27 +- apps/desktop/src/i18n/locales/zh-TW.ts | 27 +- .../__tests__/redis/RedisExpiryWiring.spec.ts | 185 +++++++ .../__tests__/redis/redisAutoRefresh.spec.ts | 16 +- .../backend/__tests__/redisExpiryApi.spec.ts | 99 ++++ apps/desktop/src/lib/backend/api.ts | 1 + apps/desktop/src/lib/backend/http.ts | 4 + apps/desktop/src/lib/backend/tauri.ts | 4 + .../desktop/src/lib/redis/redisAutoRefresh.ts | 13 + .../desktop/src/lib/redis/redisExpiry.spec.ts | 73 +++ apps/desktop/src/lib/redis/redisExpiry.ts | 57 ++ crates/dbx-core/src/db/redis_driver.rs | 66 ++- crates/dbx-core/src/redis_ops.rs | 29 + crates/dbx-web/src/main.rs | 2 + crates/dbx-web/src/routes/redis.rs | 49 ++ package.json | 1 + pnpm-lock.yaml | 3 + src-tauri/src/commands/redis_cmd.rs | 14 + src-tauri/src/lib.rs | 1 + 35 files changed, 2918 insertions(+), 98 deletions(-) create mode 100644 apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts create mode 100644 apps/desktop/src/components/redis/RedisValueViewer.expiry.spec.ts create mode 100644 apps/desktop/src/components/ui/date-time-picker/DateTimePicker.dialog.spec.ts create mode 100644 apps/desktop/src/components/ui/date-time-picker/DateTimePicker.spec.ts create mode 100644 apps/desktop/src/components/ui/date-time-picker/DateTimePicker.vue create mode 100644 apps/desktop/src/components/ui/date-time-picker/dateTimePicker.ts create mode 100644 apps/desktop/src/components/ui/date-time-picker/dateTimePickerHelpers.spec.ts create mode 100644 apps/desktop/src/components/ui/date-time-picker/index.ts create mode 100644 apps/desktop/src/lib/__tests__/redis/RedisExpiryWiring.spec.ts create mode 100644 apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisExpiry.spec.ts create mode 100644 apps/desktop/src/lib/redis/redisExpiry.ts diff --git a/apps/desktop/src/components/grid/__tests__/DataGridClipboardRegion.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridClipboardRegion.spec.ts index ffb9acdbf..7defcb1a4 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridClipboardRegion.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridClipboardRegion.spec.ts @@ -5,10 +5,9 @@ const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), describe("DataGrid native clipboard regions", () => { it("keeps table info text selection out of grid copy shortcuts", () => { - const drawerStart = dataGridSource.indexOf('
", drawerStart); + const drawerTag = dataGridSource.match(/]*>/)?.[0]; - expect(drawerStart).toBeGreaterThanOrEqual(0); - expect(dataGridSource.slice(drawerStart, drawerTagEnd)).toContain("data-native-clipboard"); + expect(drawerTag).toBeDefined(); + expect(drawerTag).toContain("data-native-clipboard"); }); }); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts new file mode 100644 index 000000000..1d7b2cd8b --- /dev/null +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.expiry.spec.ts @@ -0,0 +1,524 @@ +// @vitest-environment happy-dom + +import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date"; +import { createApp, nextTick, type ComponentPublicInstance } from "vue"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker"; + +const mocks = vi.hoisted(() => ({ + redisScanKeysBatch: vi.fn(), + redisGetValue: vi.fn(), + redisSetString: vi.fn(), + redisJsonSet: vi.fn(), + redisHashSet: vi.fn(), + redisListPush: vi.fn(), + redisSetAdd: vi.fn(), + redisZadd: vi.fn(), + redisStreamAdd: vi.fn(), + redisSetTtl: vi.fn(), + redisSetExpireAt: vi.fn(), + redisCheckJsonModule: vi.fn(), + redisDeleteKey: vi.fn(), + redisDeleteKeys: vi.fn(), + toast: vi.fn(), + updateRedisDbKeyStats: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + redisScanKeysBatch: mocks.redisScanKeysBatch, + redisGetValue: mocks.redisGetValue, + redisSetString: mocks.redisSetString, + redisJsonSet: mocks.redisJsonSet, + redisHashSet: mocks.redisHashSet, + redisListPush: mocks.redisListPush, + redisSetAdd: mocks.redisSetAdd, + redisZadd: mocks.redisZadd, + redisStreamAdd: mocks.redisStreamAdd, + redisSetTtl: mocks.redisSetTtl, + redisSetExpireAt: mocks.redisSetExpireAt, + redisCheckJsonModule: mocks.redisCheckJsonModule, + redisDeleteKey: mocks.redisDeleteKey, + redisDeleteKeys: mocks.redisDeleteKeys, +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: vi.fn().mockResolvedValue(undefined), + getConfig: () => ({ name: "Redis", redis_key_separator: ":", redis_scan_page_size: 100 }), + updateRedisDbKeyStats: mocks.updateRedisDbKeyStats, + invalidateCompletionCache: vi.fn(), + refreshRedisDbKeyCounts: vi.fn(), + }), +})); + +vi.mock("@/composables/useEditorFontFamilyStyle", () => ({ + useEditorFontFamilyStyle: () => ({}), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + inheritAttrs: false, + props: { disabled: Boolean }, + setup(props, { attrs, slots }) { + return () => h("button", { ...attrs, disabled: props.disabled }, slots.default?.()); + }, + }), + }; +}); + +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + inheritAttrs: false, + props: { modelValue: String, disabled: Boolean }, + emits: ["update:modelValue"], + setup(props, { attrs, emit }) { + return () => + h("input", { + ...attrs, + value: props.modelValue ?? "", + disabled: props.disabled, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }); + }, + }), + }; +}); + +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); + +vi.mock("@/components/ui/dialog", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Dialog: defineComponent({ + props: { open: Boolean }, + setup(props, { slots }) { + return () => (props.open ? h("div", { "data-test-dialog": "" }, slots.default?.()) : null); + }, + }), + DialogContent: slotContainer, + DialogFooter: slotContainer, + DialogHeader: slotContainer, + DialogTitle: slotContainer, + }; +}); + +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + type SelectRoot = HTMLElement & { selectTestValue?: (value: string) => void }; + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { + Select: defineComponent({ + inheritAttrs: false, + props: { modelValue: String, disabled: Boolean }, + emits: ["update:modelValue", "update:open"], + setup(props, { emit, slots }) { + const selectValue = (value: string) => { + if (!props.disabled) emit("update:modelValue", value); + }; + return () => + h( + "div", + { + "data-test-select-root": "", + ref: (element: Element | ComponentPublicInstance | null) => { + if (element instanceof HTMLElement) (element as SelectRoot).selectTestValue = selectValue; + }, + }, + slots.default?.(), + ); + }, + }), + SelectContent: slotContainer, + SelectItem: defineComponent({ + props: { value: String }, + setup(props, { slots }) { + return () => h("button", { type: "button", "data-test-select-value": props.value }, slots.default?.()); + }, + }), + SelectTrigger: slotContainer, + SelectValue: slotContainer, + }; +}); + +vi.mock("@/components/ui/option-help-panel", async () => { + const { defineComponent, h } = await import("vue"); + return { OptionHelpPanel: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("@/components/ui/tabs", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tabs: slotContainer, TabsContent: slotContainer, TabsList: slotContainer, TabsTrigger: slotContainer }; +}); + +vi.mock("@/components/ui/switch", async () => { + const { defineComponent, h } = await import("vue"); + return { Switch: defineComponent({ setup: () => () => h("button", { type: "button" }) }) }; +}); + +vi.mock("@/components/ui/date-time-picker/DateTimePicker.vue", async () => { + const { CalendarDateTime } = await import("@internationalized/date"); + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + props: { disabled: Boolean }, + emits: ["update:modelValue"], + setup(props, { emit }) { + return () => + h( + "button", + { + type: "button", + disabled: props.disabled, + "data-test-absolute-date": "", + onClick: () => emit("update:modelValue", new CalendarDateTime(2030, 1, 2, 3, 4, 5)), + }, + "Set date", + ); + }, + }), + }; +}); + +vi.mock("@/components/ui/CustomContextMenu.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + setup(_, { slots }) { + return () => h("div", slots.default?.({ onContextMenu: () => undefined })); + }, + }), + }; +}); + +vi.mock("@/components/editor/DangerConfirmDialog.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisValueViewer.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisPubSubPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("./RedisSlowlogPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { default: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("vue-virtual-scroller", async () => { + const { defineComponent, h } = await import("vue"); + return { RecycleScroller: defineComponent({ setup: () => () => h("div") }) }; +}); + +vi.mock("splitpanes", async () => { + const { defineComponent, h } = await import("vue"); + const slotContainer = defineComponent({ + setup: + (_, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Splitpanes: slotContainer, Pane: slotContainer }; +}); + +import RedisKeyBrowser from "./RedisKeyBrowser.vue"; + +const KEY_NAME = "new-key"; +const KEY_RAW = "bmV3LWtleQ=="; +const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = []; + +type CreateType = "string" | "hash" | "list" | "set" | "zset" | "stream" | "json"; +type TestSelectRoot = HTMLElement & { selectTestValue?: (value: string) => void }; +function redisValue(keyRaw = KEY_RAW) { + return { + key_display: KEY_NAME, + key_raw: keyRaw, + ttl: 90, + redis_type: "string" as const, + data: { kind: "string" as const, content: { raw_base64: "dmFsdWU=", encoding: "utf8" as const } }, + }; +} + +function redisKeyInfo(keyType = "json") { + return { key_display: KEY_NAME, key_raw: KEY_RAW, key_type: keyType, ttl: 90, size: 7, value_preview: "{}" }; +} + +function resetApiMocks() { + vi.clearAllMocks(); + mocks.redisScanKeysBatch.mockResolvedValue({ cursor: 0, keys: [], total_keys: 0 }); + mocks.redisGetValue.mockImplementation((_connectionId: string, _db: number, keyRaw: string) => Promise.resolve(redisValue(keyRaw))); + mocks.redisSetString.mockResolvedValue(undefined); + mocks.redisJsonSet.mockResolvedValue(undefined); + mocks.redisHashSet.mockResolvedValue(undefined); + mocks.redisListPush.mockResolvedValue(undefined); + mocks.redisSetAdd.mockResolvedValue(undefined); + mocks.redisZadd.mockResolvedValue(undefined); + mocks.redisStreamAdd.mockResolvedValue(undefined); + mocks.redisSetTtl.mockResolvedValue(undefined); + mocks.redisSetExpireAt.mockResolvedValue(undefined); + mocks.redisCheckJsonModule.mockResolvedValue(true); + mocks.redisDeleteKey.mockResolvedValue(undefined); + mocks.redisDeleteKeys.mockResolvedValue(0); +} + +function mountBrowser() { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp(RedisKeyBrowser, { connectionId: "connection", db: 0, blockDangerousRedisCommands: false }); + app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false })); + app.mount(host); + mountedApps.push({ unmount: () => app.unmount(), host }); +} + +async function settle() { + await nextTick(); + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +} + +function requiredElement(selector: string): T { + const element = document.querySelector(selector); + expect(element, selector).not.toBeNull(); + return element!; +} + +function clickButtonWithText(text: string) { + const button = Array.from(document.querySelectorAll("button")).find((candidate) => candidate.textContent?.includes(text)); + expect(button, text).toBeDefined(); + button!.click(); +} + +async function setInput(selector: string, value: string) { + const input = requiredElement(selector); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); +} + +async function select(value: string) { + const item = requiredElement(`[data-test-select-value="${value}"]`); + const root = item.closest("[data-test-select-root]"); + expect(root).not.toBeNull(); + expect(root?.selectTestValue).toEqual(expect.any(Function)); + root!.selectTestValue!(value); + await settle(); +} + +async function openCreateDialog() { + requiredElement('button[title="redis.createKey"]').click(); + await settle(); + await setInput('input[placeholder="redis.createKeyNamePlaceholder"]', KEY_NAME); +} + +async function fillCreateValue(type: CreateType) { + if (type === "string") { + const textarea = requiredElement("textarea"); + textarea.value = "value"; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + return; + } + + await select(type); + if (type === "json") { + const textarea = requiredElement("textarea"); + textarea.value = '{"value":true}'; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + await settle(); + return; + } + + if (type === "hash" || type === "stream") { + await setInput('input[placeholder="redis.createFieldPlaceholder"]', "field"); + await setInput('input[placeholder="redis.createValuePlaceholder"]', "value"); + return; + } + + if (type === "zset") { + await setInput('input[placeholder="0"]', "1"); + await setInput('input[placeholder="redis.createMember"]', "member"); + return; + } + + await setInput('input[placeholder="redis.createValuePlaceholder"]', "value"); +} + +async function submitCreate() { + clickButtonWithText("redis.createKeySubmit"); + await settle(); +} + +function expectWriterBefore(mock: { mock: { invocationCallOrder: number[] } }, after: { mock: { invocationCallOrder: number[] } }) { + expect(mock.mock.invocationCallOrder).toHaveLength(1); + expect(after.mock.invocationCallOrder).toHaveLength(1); + expect(mock.mock.invocationCallOrder[0]).toBeLessThan(after.mock.invocationCallOrder[0]!); +} + +const writerForType = { + string: mocks.redisSetString, + hash: mocks.redisHashSet, + list: mocks.redisListPush, + set: mocks.redisSetAdd, + zset: mocks.redisZadd, + stream: mocks.redisStreamAdd, + json: mocks.redisJsonSet, +} as const; + +beforeEach(() => { + resetApiMocks(); + setLocalTimeZone("UTC"); +}); + +afterEach(() => { + for (const { unmount, host } of mountedApps.splice(0)) { + unmount(); + host.remove(); + } + resetLocalTimeZone(); +}); + +describe("RedisKeyBrowser expiry creation", () => { + it.each(["string", "hash", "list", "set", "zset", "stream", "json"] as const)("writes %s before applying one relative TTL", async (type) => { + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue(type); + await select("ttl"); + await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90"); + + await submitCreate(); + + const writer = writerForType[type]; + expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, KEY_RAW, 90); + expect(mocks.redisSetExpireAt).not.toHaveBeenCalled(); + expectWriterBefore(writer, mocks.redisSetTtl); + }); + + it("uses PERSIST after a String write when no expiry is selected", async () => { + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue("string"); + + await submitCreate(); + + expect(mocks.redisSetTtl).toHaveBeenCalledWith("connection", 0, KEY_RAW, -1); + expect(mocks.redisSetExpireAt).not.toHaveBeenCalled(); + expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl); + }); + + it("uses EXPIREAT after a String write when an absolute time is selected", async () => { + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue("string"); + await select("at"); + requiredElement("[data-test-absolute-date]").click(); + await settle(); + + await submitCreate(); + + const expected = calendarDateTimeToUnixSeconds(new CalendarDateTime(2030, 1, 2, 3, 4, 5)); + expect(mocks.redisSetExpireAt).toHaveBeenCalledWith("connection", 0, KEY_RAW, expected); + expect(mocks.redisSetTtl).not.toHaveBeenCalled(); + expectWriterBefore(mocks.redisSetString, mocks.redisSetExpireAt); + }); + + it("does not roll back a written key when its expiry command fails and refreshes it", async () => { + mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed")); + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue("string"); + await select("ttl"); + await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90"); + + await submitCreate(); + + expectWriterBefore(mocks.redisSetString, mocks.redisSetTtl); + expect(mocks.redisGetValue).toHaveBeenCalledWith("connection", 0, KEY_RAW); + expect(mocks.redisDeleteKey).not.toHaveBeenCalled(); + expect(mocks.redisDeleteKeys).not.toHaveBeenCalled(); + expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000); + }); + + it("removes an existing RedisJSON key only after recovery confirms its deletion", async () => { + mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [redisKeyInfo()], total_keys: 1 }); + mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed")); + mocks.redisGetValue.mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockRejectedValueOnce(new Error("RedisJSON key no longer exists")); + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue("json"); + await select("ttl"); + await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90"); + + await submitCreate(); + await settle(); + + expect(mocks.redisGetValue).toHaveBeenCalledTimes(2); + expect(mocks.updateRedisDbKeyStats).toHaveBeenCalledWith("connection", 0, { loaded: 0, totalDelta: -1 }); + expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000); + }); + + it("keeps an existing RedisJSON key when retry cannot confirm its deletion", async () => { + mocks.redisScanKeysBatch.mockResolvedValueOnce({ cursor: 0, keys: [redisKeyInfo()], total_keys: 1 }); + mocks.redisSetTtl.mockRejectedValueOnce(new Error("TTL command failed")); + mocks.redisGetValue.mockRejectedValueOnce(new Error("RedisJSON key no longer exists")).mockRejectedValueOnce(new Error("network unavailable")); + mountBrowser(); + await settle(); + await openCreateDialog(); + await fillCreateValue("json"); + await select("ttl"); + await setInput('input[placeholder="redis.createKeyTtlPlaceholder"]', "90"); + + await submitCreate(); + await settle(); + + expect(mocks.updateRedisDbKeyStats).not.toHaveBeenCalledWith("connection", 0, { loaded: 0, totalDelta: -1 }); + expect(mocks.toast).toHaveBeenCalledWith("TTL command failed", 5000); + }); +}); diff --git a/apps/desktop/src/components/redis/RedisKeyBrowser.vue b/apps/desktop/src/components/redis/RedisKeyBrowser.vue index 8d3dfb2d5..921f433df 100644 --- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue +++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue @@ -1,5 +1,6 @@ + + diff --git a/apps/desktop/src/components/ui/date-time-picker/dateTimePicker.ts b/apps/desktop/src/components/ui/date-time-picker/dateTimePicker.ts new file mode 100644 index 000000000..008aca324 --- /dev/null +++ b/apps/desktop/src/components/ui/date-time-picker/dateTimePicker.ts @@ -0,0 +1,89 @@ +import { CalendarDateTime, fromDate, getLocalTimeZone, parseDateTime, toCalendarDateTime } from "@internationalized/date"; + +const LOCAL_DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/; + +/** + * The fields shared by CalendarDateTime and Vue's unwrapped representation of + * it. Date values are normalized before calling methods that use private state. + */ +export interface CalendarDateTimeLike { + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; + readonly second: number; + readonly millisecond: number; +} + +/** + * Parses a local, timezone-free ISO date-time. Both `T` and a space are accepted + * between the date and time, and an omitted seconds segment defaults to zero. + */ +export function parseLocalDateTime(value: string): CalendarDateTime | null { + const match = LOCAL_DATE_TIME_PATTERN.exec(value.trim()); + if (!match) return null; + + const [, yearText, monthText, dayText, hourText, minuteText, secondText = "00"] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if (year < 1 || month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return null; + + try { + return parseDateTime(`${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}`); + } catch { + return null; + } +} + +/** Formats a timezone-free value as the local ISO representation used by the picker. */ +export function formatLocalDateTime(value: CalendarDateTimeLike): string { + return [pad(value.year, 4), pad(value.month), pad(value.day)].join("-") + ` ${pad(value.hour)}:${pad(value.minute)}:${pad(value.second)}`; +} + +/** + * Interprets the timezone-free wall-clock value in the DBX device's system time + * zone before converting it to Unix seconds. + */ +export function calendarDateTimeToUnixSeconds(value: CalendarDateTimeLike): number { + // DST gaps and overlaps do not identify exactly one local instant. Reject them + // instead of silently changing the wall-clock time chosen by the user. + return Math.floor(calendarDateTimeFromFields(value).toDate(getLocalTimeZone(), "reject").getTime() / 1_000); +} + +/** True when a local wall-clock value identifies exactly one system-timezone instant. */ +export function isValidLocalDateTime(value: CalendarDateTimeLike): boolean { + try { + calendarDateTimeToUnixSeconds(value); + return true; + } catch { + return false; + } +} + +/** Converts Unix seconds to a timezone-free value in the DBX device's system time zone. */ +export function unixSecondsToCalendarDateTime(seconds: number): CalendarDateTime { + if (!Number.isFinite(seconds)) throw new RangeError("Unix seconds must be a finite number"); + + const date = new Date(Math.trunc(seconds) * 1_000); + if (Number.isNaN(date.getTime())) throw new RangeError("Unix seconds are outside the Date range"); + + const local = toCalendarDateTime(fromDate(date, getLocalTimeZone())); + return local.set({ millisecond: 0 }); +} + +export function currentLocalDateTime(): CalendarDateTime { + return unixSecondsToCalendarDateTime(Math.floor(Date.now() / 1_000)); +} + +export function calendarDateTimeFromFields(value: CalendarDateTimeLike): CalendarDateTime { + return new CalendarDateTime(value.year, value.month, value.day, value.hour, value.minute, value.second, value.millisecond); +} + +function pad(value: number, width = 2): string { + return String(value).padStart(width, "0"); +} diff --git a/apps/desktop/src/components/ui/date-time-picker/dateTimePickerHelpers.spec.ts b/apps/desktop/src/components/ui/date-time-picker/dateTimePickerHelpers.spec.ts new file mode 100644 index 000000000..dbc1d537b --- /dev/null +++ b/apps/desktop/src/components/ui/date-time-picker/dateTimePickerHelpers.spec.ts @@ -0,0 +1,44 @@ +import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date"; +import { afterEach, describe, expect, it } from "vitest"; +import { calendarDateTimeToUnixSeconds, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "./dateTimePicker"; + +afterEach(() => { + resetLocalTimeZone(); +}); + +describe("local date-time helpers", () => { + it("parses local ISO text with a space or T separator at second precision", () => { + const space = parseLocalDateTime("2024-02-29 13:45:06"); + const t = parseLocalDateTime("2024-02-29T13:45"); + + expect(space && formatLocalDateTime(space)).toBe("2024-02-29 13:45:06"); + expect(t && formatLocalDateTime(t)).toBe("2024-02-29 13:45:00"); + }); + + it("rejects empty, malformed, and impossible local date-times", () => { + expect(parseLocalDateTime("")).toBeNull(); + expect(parseLocalDateTime("2024-02-30 12:00:00")).toBeNull(); + expect(parseLocalDateTime("2024-01-01T24:00:00")).toBeNull(); + expect(parseLocalDateTime("2024-01-01 12:00:00Z")).toBeNull(); + }); + + it("converts through the local timezone rather than treating the wall-clock value as UTC", () => { + setLocalTimeZone("America/Los_Angeles"); + const local = new CalendarDateTime(2024, 1, 15, 12, 34, 56); + const seconds = calendarDateTimeToUnixSeconds(local); + + expect(new Date(seconds * 1_000).toISOString()).toBe("2024-01-15T20:34:56.000Z"); + expect(formatLocalDateTime(unixSecondsToCalendarDateTime(seconds))).toBe("2024-01-15 12:34:56"); + }); + + it("rejects local times that are skipped or repeated by daylight-saving transitions", () => { + setLocalTimeZone("America/Los_Angeles"); + const skipped = new CalendarDateTime(2024, 3, 10, 2, 30, 0); + const repeated = new CalendarDateTime(2024, 11, 3, 1, 30, 0); + + expect(isValidLocalDateTime(skipped)).toBe(false); + expect(isValidLocalDateTime(repeated)).toBe(false); + expect(() => calendarDateTimeToUnixSeconds(skipped)).toThrow(); + expect(() => calendarDateTimeToUnixSeconds(repeated)).toThrow(); + }); +}); diff --git a/apps/desktop/src/components/ui/date-time-picker/index.ts b/apps/desktop/src/components/ui/date-time-picker/index.ts new file mode 100644 index 000000000..3ce4f0d68 --- /dev/null +++ b/apps/desktop/src/components/ui/date-time-picker/index.ts @@ -0,0 +1,2 @@ +export { default as DateTimePicker } from "./DateTimePicker.vue"; +export { calendarDateTimeFromFields, calendarDateTimeToUnixSeconds, currentLocalDateTime, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime, type CalendarDateTimeLike } from "./dateTimePicker"; diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 2a753a929..ca8e7a1d5 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1398,6 +1398,22 @@ export default { delete: "Delete", back: "Back", }, + dateTimePicker: { + open: "Open date and time picker", + inputLabel: "Date and time", + inputPlaceholder: "YYYY-MM-DD HH:mm:ss", + previousMonth: "Previous month", + nextMonth: "Next month", + calendarLabel: "Calendar", + timeLabel: "Time", + hour: "Hour", + minute: "Minute", + second: "Second", + cancel: "Cancel", + apply: "Apply", + invalidDateTime: "Enter a valid date and time", + outOfRange: "Date and time is outside the allowed range", + }, quickOpen: { placeholder: "Search connections, databases, tables, SQL files, and other objects...", emptyPlaceholder: "Start typing to search", @@ -2728,6 +2744,14 @@ export default { loadedMembers: "{loaded} of {total} members loaded", entries: "{count} entries", noExpiry: "no expiry", + expiry: "Expiration", + expiryNone: "No expiry", + expiryTtl: "TTL (seconds)", + expiryAt: "Expiration time (local)", + expiryTtlInvalid: "Enter a positive whole-number TTL in seconds", + expiryDateRequired: "Select a valid expiration time", + expiryDatePast: "Expiration time must be in the future", + keyExpiredBeforeDisplay: "The key expired before it could be displayed.", autoRefresh: "Auto-refresh countdown", ttlDay: "{count}d", columnType: "Type", @@ -2774,11 +2798,12 @@ export default { createFieldPlaceholder: "name", createScore: "Score", createKeyTtl: "TTL (seconds)", - createKeyTtlPlaceholder: "empty = no expiry", + createKeyTtlPlaceholder: "positive whole seconds", createKeyAddEntry: "Add entry", createKeyEntryId: "Entry ID", createKeyRawMode: "Raw input", createKeyEntries: "Entries", + createKeyPartialWrite: "Some entries were written. Fix the error and create again to write the remaining entries.", createValuePlaceholder: "value", createKeySubmit: "Create", createKeyNameRequired: "Enter a key name", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f2715d2e8..23cd847a2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1341,6 +1341,22 @@ export default withEnglishFallback({ delete: "Eliminar", back: "Volver", }, + dateTimePicker: { + open: "Abrir selector de fecha y hora", + inputLabel: "Fecha y hora", + inputPlaceholder: "AAAA-MM-DD HH:mm:ss", + previousMonth: "Mes anterior", + nextMonth: "Mes siguiente", + calendarLabel: "Calendario", + timeLabel: "Hora", + hour: "Hora", + minute: "Minuto", + second: "Segundo", + cancel: "Cancelar", + apply: "Aplicar", + invalidDateTime: "Ingresa una fecha y hora válidas", + outOfRange: "La fecha y hora están fuera del intervalo permitido", + }, quickOpen: { placeholder: "Buscar conexiones, bases de datos, tablas, archivos SQL y otros objetos...", emptyPlaceholder: "Comienza a escribir para buscar", @@ -2589,6 +2605,14 @@ export default withEnglishFallback({ loadedMembers: "{loaded} de {total} miembros cargados", entries: "{count} entradas", noExpiry: "sin expiración", + expiry: "Expiración", + expiryNone: "Sin expiración", + expiryTtl: "TTL (segundos)", + expiryAt: "Hora de expiración (local)", + expiryTtlInvalid: "Ingresa un TTL positivo en segundos", + expiryDateRequired: "Selecciona una hora de expiración válida", + expiryDatePast: "La hora de expiración debe ser futura", + keyExpiredBeforeDisplay: "La clave expiró antes de que pudiera mostrarse.", autoRefresh: "Actualización automática", ttlDay: "{count}d", columnType: "Tipo", @@ -2626,11 +2650,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "name", createScore: "Puntuación", createKeyTtl: "TTL (segundos)", - createKeyTtlPlaceholder: "vacío = sin expiración", + createKeyTtlPlaceholder: "segundos enteros positivos", createKeyAddEntry: "Agregar entrada", createKeyEntryId: "ID de entrada", createKeyRawMode: "Entrada bruta", createKeyEntries: "Entradas", + createKeyPartialWrite: "Se escribieron algunas entradas. Corrige el error y vuelve a crear para escribir las restantes.", createValuePlaceholder: "valor", jsonModuleNotAvailable: "El módulo ReJSON no está cargado en este servidor Redis", createKeySubmit: "Crear", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3d68a364d..30e708e4c 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1339,6 +1339,22 @@ export default withEnglishFallback({ delete: "Elimina", back: "Indietro", }, + dateTimePicker: { + open: "Apri selettore data e ora", + inputLabel: "Data e ora", + inputPlaceholder: "AAAA-MM-GG HH:mm:ss", + previousMonth: "Mese precedente", + nextMonth: "Mese successivo", + calendarLabel: "Calendario", + timeLabel: "Ora", + hour: "Ora", + minute: "Minuto", + second: "Secondo", + cancel: "Annulla", + apply: "Applica", + invalidDateTime: "Inserisci una data e ora valide", + outOfRange: "La data e ora non rientra nell'intervallo consentito", + }, quickOpen: { placeholder: "Cerca connessioni, database, tabelle, file SQL e altri oggetti...", emptyPlaceholder: "Inizia a digitare per cercare", @@ -2587,6 +2603,14 @@ export default withEnglishFallback({ loadedMembers: "{loaded} di {total} membri caricati", entries: "{count} voci", noExpiry: "nessuna scadenza", + expiry: "Scadenza", + expiryNone: "Nessuna scadenza", + expiryTtl: "TTL (secondi)", + expiryAt: "Data e ora di scadenza (locale)", + expiryTtlInvalid: "Inserisci un TTL positivo in secondi", + expiryDateRequired: "Seleziona una data e ora di scadenza valida", + expiryDatePast: "La data e ora di scadenza deve essere nel futuro", + keyExpiredBeforeDisplay: "La chiave è scaduta prima di poter essere visualizzata.", autoRefresh: "Aggiornamento automatico", ttlDay: "{count}g", columnType: "Tipo", @@ -2624,11 +2648,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "nome", createScore: "Punteggio", createKeyTtl: "TTL (secondi)", - createKeyTtlPlaceholder: "vuoto = senza scadenza", + createKeyTtlPlaceholder: "secondi interi positivi", createKeyAddEntry: "Aggiungi voce", createKeyEntryId: "ID voce", createKeyRawMode: "Input raw", createKeyEntries: "Voci", + createKeyPartialWrite: "Alcune voci sono state scritte. Correggi l'errore e crea di nuovo per scrivere le voci rimanenti.", createValuePlaceholder: "valore", createKeySubmit: "Crea", createKeyNameRequired: "Inserisci un nome chiave", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index f844c91c0..08d934cd4 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1340,6 +1340,22 @@ export default withEnglishFallback({ delete: "削除", back: "戻る", }, + dateTimePicker: { + open: "日時ピッカーを開く", + inputLabel: "日時", + inputPlaceholder: "YYYY-MM-DD HH:mm:ss", + previousMonth: "前の月", + nextMonth: "次の月", + calendarLabel: "カレンダー", + timeLabel: "時刻", + hour: "時", + minute: "分", + second: "秒", + cancel: "キャンセル", + apply: "適用", + invalidDateTime: "有効な日時を入力してください", + outOfRange: "日時が許可された範囲外です", + }, quickOpen: { placeholder: "接続、データベース、テーブル、SQLファイル、その他のオブジェクトを検索...", emptyPlaceholder: "入力して検索を開始", @@ -2588,6 +2604,14 @@ export default withEnglishFallback({ loadedMembers: "{loaded}/{total}メンバー読み込み完了", entries: "{count}エントリ", noExpiry: "期限なし", + expiry: "有効期限", + expiryNone: "期限なし", + expiryTtl: "TTL(秒)", + expiryAt: "有効期限日時(ローカル)", + expiryTtlInvalid: "正の整数の TTL(秒)を入力してください", + expiryDateRequired: "有効な有効期限日時を選択してください", + expiryDatePast: "有効期限日時は現在より後にしてください", + keyExpiredBeforeDisplay: "キーは表示される前に期限切れになりました。", autoRefresh: "自動更新(カウントダウン)", ttlDay: "{count}日", columnType: "型", @@ -2625,11 +2649,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "name", createScore: "スコア", createKeyTtl: "TTL(秒)", - createKeyTtlPlaceholder: "空 = 期限なし", + createKeyTtlPlaceholder: "正の整数秒", createKeyAddEntry: "エントリを追加", createKeyEntryId: "エントリID", createKeyRawMode: "直接入力", createKeyEntries: "エントリ", + createKeyPartialWrite: "一部のエントリは書き込まれました。エラーを修正してから、もう一度作成して残りのエントリを書き込んでください。", createValuePlaceholder: "value", createKeySubmit: "作成", createKeyNameRequired: "キー名を入力してください", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 10d38c405..2d5fa67f8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1341,6 +1341,22 @@ export default withEnglishFallback({ delete: "Excluir", back: "Voltar", }, + dateTimePicker: { + open: "Abrir seletor de data e hora", + inputLabel: "Data e hora", + inputPlaceholder: "AAAA-MM-DD HH:mm:ss", + previousMonth: "Mês anterior", + nextMonth: "Próximo mês", + calendarLabel: "Calendário", + timeLabel: "Hora", + hour: "Hora", + minute: "Minuto", + second: "Segundo", + cancel: "Cancelar", + apply: "Aplicar", + invalidDateTime: "Insira uma data e hora válidas", + outOfRange: "A data e hora está fora do intervalo permitido", + }, quickOpen: { placeholder: "Pesquisar conexões, bancos de dados, tabelas, arquivos SQL e outros objetos...", emptyPlaceholder: "Comece a digitar para pesquisar", @@ -2589,6 +2605,14 @@ export default withEnglishFallback({ loadedMembers: "{loaded} de {total} membros carregados", entries: "{count} entradas", noExpiry: "sem expiração", + expiry: "Expiração", + expiryNone: "Sem expiração", + expiryTtl: "TTL (segundos)", + expiryAt: "Data e hora de expiração (local)", + expiryTtlInvalid: "Insira um TTL positivo em segundos", + expiryDateRequired: "Selecione uma data e hora de expiração válida", + expiryDatePast: "A data e hora de expiração deve ser futura", + keyExpiredBeforeDisplay: "A chave expirou antes de poder ser exibida.", autoRefresh: "Atualização automática", ttlDay: "{count}d", columnType: "Tipo", @@ -2626,11 +2650,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "nome", createScore: "Pontuação", createKeyTtl: "TTL (segundos)", - createKeyTtlPlaceholder: "vazio = sem expiração", + createKeyTtlPlaceholder: "segundos inteiros positivos", createKeyAddEntry: "Adicionar entrada", createKeyEntryId: "ID da entrada", createKeyRawMode: "Entrada bruta", createKeyEntries: "Entradas", + createKeyPartialWrite: "Algumas entradas foram gravadas. Corrija o erro e crie novamente para gravar as entradas restantes.", createValuePlaceholder: "valor", jsonModuleNotAvailable: "Módulo ReJSON não está carregado neste servidor Redis", createKeySubmit: "Criar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index ea9059310..aa366ff41 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1399,6 +1399,22 @@ export default withEnglishFallback({ delete: "删除", back: "返回", }, + dateTimePicker: { + open: "打开日期时间选择器", + inputLabel: "日期和时间", + inputPlaceholder: "YYYY-MM-DD HH:mm:ss", + previousMonth: "上个月", + nextMonth: "下个月", + calendarLabel: "日历", + timeLabel: "时间", + hour: "时", + minute: "分", + second: "秒", + cancel: "取消", + apply: "应用", + invalidDateTime: "请输入有效的日期和时间", + outOfRange: "日期和时间超出允许范围", + }, quickOpen: { placeholder: "搜索连接、数据库、表、SQL文件和其他对象...", emptyPlaceholder: "开始输入进行搜索", @@ -2728,6 +2744,14 @@ export default withEnglishFallback({ loadedMembers: "已加载 {loaded} / 共 {total} 个成员", entries: "{count} 条记录", noExpiry: "永不过期", + expiry: "过期时间", + expiryNone: "永不过期", + expiryTtl: "TTL(秒)", + expiryAt: "过期时间(本地时间)", + expiryTtlInvalid: "请输入正整数 TTL(秒)", + expiryDateRequired: "请选择有效的过期时间", + expiryDatePast: "过期时间必须晚于当前时间", + keyExpiredBeforeDisplay: "该键在显示前已过期。", autoRefresh: "自动刷新(倒计时)", ttlDay: "{count}天", columnType: "类型", @@ -2774,11 +2798,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "name", createScore: "分数", createKeyTtl: "TTL(秒)", - createKeyTtlPlaceholder: "留空表示永不过期", + createKeyTtlPlaceholder: "正整数秒", createKeyAddEntry: "添加条目", createKeyEntryId: "条目 ID", createKeyRawMode: "原始输入", createKeyEntries: "条目", + createKeyPartialWrite: "部分条目已写入。请修复错误后再次创建以写入剩余条目。", createValuePlaceholder: "value", createKeySubmit: "新增", createKeyNameRequired: "请输入 key 名称", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index c7ff6e644..4daecd3a0 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1340,6 +1340,22 @@ export default withEnglishFallback({ delete: "刪除", back: "返回", }, + dateTimePicker: { + open: "開啟日期時間選擇器", + inputLabel: "日期和時間", + inputPlaceholder: "YYYY-MM-DD HH:mm:ss", + previousMonth: "上個月", + nextMonth: "下個月", + calendarLabel: "日曆", + timeLabel: "時間", + hour: "時", + minute: "分", + second: "秒", + cancel: "取消", + apply: "套用", + invalidDateTime: "請輸入有效的日期和時間", + outOfRange: "日期和時間超出允許範圍", + }, quickOpen: { placeholder: "搜尋連線、資料庫、資料表、SQL檔案和其他物件……", emptyPlaceholder: "開始輸入進行搜尋", @@ -2400,6 +2416,14 @@ export default withEnglishFallback({ loadedMembers: "已載入 {loaded} / 共 {total} 個成員", entries: "{count} 筆項目", noExpiry: "永不過期", + expiry: "到期時間", + expiryNone: "永不過期", + expiryTtl: "TTL(秒)", + expiryAt: "到期時間(本地時間)", + expiryTtlInvalid: "請輸入正整數 TTL(秒)", + expiryDateRequired: "請選擇有效的到期時間", + expiryDatePast: "到期時間必須晚於目前時間", + keyExpiredBeforeDisplay: "此鍵在顯示前已過期。", autoRefresh: "自動刷新(倒計時)", ttlDay: "{count}天", columnType: "類型", @@ -2437,11 +2461,12 @@ export default withEnglishFallback({ createFieldPlaceholder: "name", createScore: "分數", createKeyTtl: "TTL(秒)", - createKeyTtlPlaceholder: "留空表示永不過期", + createKeyTtlPlaceholder: "正整數秒", createKeyAddEntry: "新增項目", createKeyEntryId: "項目 ID", createKeyRawMode: "原始輸入", createKeyEntries: "項目", + createKeyPartialWrite: "部分項目已寫入。請修正錯誤後再次建立以寫入其餘項目。", createValuePlaceholder: "value", createKeySubmit: "建立", createKeyNameRequired: "請輸入 key 名稱", diff --git a/apps/desktop/src/lib/__tests__/redis/RedisExpiryWiring.spec.ts b/apps/desktop/src/lib/__tests__/redis/RedisExpiryWiring.spec.ts new file mode 100644 index 000000000..97b5973f8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/redis/RedisExpiryWiring.spec.ts @@ -0,0 +1,185 @@ +import { readFileSync } from "node:fs"; +import { parse } from "vue/compiler-sfc"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const browserSource = readFileSync(new URL("../../../components/redis/RedisKeyBrowser.vue", import.meta.url), "utf8"); +const viewerSource = readFileSync(new URL("../../../components/redis/RedisValueViewer.vue", import.meta.url), "utf8"); +const localeSources = { + en: readFileSync(new URL("../../../i18n/locales/en.ts", import.meta.url), "utf8"), + es: readFileSync(new URL("../../../i18n/locales/es.ts", import.meta.url), "utf8"), + it: readFileSync(new URL("../../../i18n/locales/it.ts", import.meta.url), "utf8"), + ja: readFileSync(new URL("../../../i18n/locales/ja.ts", import.meta.url), "utf8"), + "pt-BR": readFileSync(new URL("../../../i18n/locales/pt-BR.ts", import.meta.url), "utf8"), + "zh-CN": readFileSync(new URL("../../../i18n/locales/zh-CN.ts", import.meta.url), "utf8"), + "zh-TW": readFileSync(new URL("../../../i18n/locales/zh-TW.ts", import.meta.url), "utf8"), +}; + +function findFunction(source: string, name: string): ts.FunctionDeclaration { + const parsed = parse(source, { filename: `${name}.vue` }); + expect(parsed.errors).toEqual([]); + const script = parsed.descriptor.scriptSetup; + expect(script).toBeDefined(); + const program = ts.createSourceFile(`${name}.ts`, script!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const declaration = program.statements.find((statement): statement is ts.FunctionDeclaration => ts.isFunctionDeclaration(statement) && statement.name?.text === name); + expect(declaration).toBeDefined(); + return declaration!; +} + +function callsIn(node: ts.Node): ts.CallExpression[] { + const calls: ts.CallExpression[] = []; + const visit = (child: ts.Node) => { + if (ts.isCallExpression(child)) calls.push(child); + ts.forEachChild(child, visit); + }; + visit(node); + return calls; +} + +function callName(call: ts.CallExpression): string | undefined { + if (ts.isIdentifier(call.expression)) return call.expression.text; + if (ts.isPropertyAccessExpression(call.expression)) return `${call.expression.expression.getText()}.${call.expression.name.text}`; + return undefined; +} + +describe("Redis expiry mode wiring", () => { + it("writes every create type before applying exactly one shared expiration policy", () => { + const create = findFunction(browserSource, "createRedisKey"); + const calls = callsIn(create); + const expectedWriterArgumentCounts: Record = { + "api.redisSetString": 4, + "api.redisJsonSet": 4, + "api.redisHashSet": 5, + "api.redisListPush": 4, + "api.redisSetAdd": 4, + "api.redisZadd": 5, + "api.redisStreamAdd": 5, + }; + + for (const [name, argumentCount] of Object.entries(expectedWriterArgumentCounts)) { + const writerCalls = calls.filter((call) => callName(call) === name); + expect(writerCalls.length, `${name} should be used by the create flow`).toBeGreaterThan(0); + expect( + writerCalls.every((call) => call.arguments.length === argumentCount), + `${name} must not receive a per-write TTL`, + ).toBe(true); + } + + expect(calls.map(callName)).toContain("applyRedisExpiryPolicy"); + expect(create.getText()).toContain("if (creatingKey.value) return;"); + expect(calls.map(callName)).not.toContain("api.redisSetTtl"); + expect(calls.map(callName)).not.toContain("api.redisSetExpireAt"); + expect(create.getText().lastIndexOf("applyRedisExpiryPolicy")).toBeGreaterThan(create.getText().lastIndexOf("api.redisStreamAdd")); + expect(findFunction(browserSource, "syncWrittenKey").getText()).toContain('if (created.redis_type === "none")'); + expect(create.getText()).toContain('toast(t("redis.keyExpiredBeforeDisplay"), 3000)'); + expect(create.getText()).toContain("if (wroteValue && writtenKeyRaw)"); + expect(findFunction(browserSource, "reflectWrittenKey").getText()).toContain("removeKnownKey(keyRaw)"); + }); + + it("keeps only unwritten structured entries after a later writer fails", () => { + const create = findFunction(browserSource, "createRedisKey").getText(); + const partialRecoveryStart = create.indexOf("if (writingStructuredEntries)"); + const genericRecoveryStart = create.lastIndexOf("if (wroteValue && writtenKeyRaw)"); + const partialRecovery = create.slice(partialRecoveryStart, genericRecoveryStart); + + expect(create).toContain("let writingStructuredEntries = false;"); + expect(create).toContain("const pendingEntries = createKeyEntries.value.slice();"); + expect(create.match(/for \(const entry of pendingEntries\)/g)).toHaveLength(4); + expect(create.match(/removeWrittenCreateKeyEntry\(entry\.id\)/g)).toHaveLength(4); + expect(create).toContain("createKeyPartiallyWritten.value = true;"); + expect(partialRecovery).toContain("await reflectWrittenKey(writtenKeyRaw);"); + expect(partialRecovery).toContain('t("redis.createKeyPartialWrite")'); + expect(partialRecovery).not.toContain("showCreateKeyDialog.value = false;"); + expect(browserSource).toContain(':disabled="creatingKey || createKeyPartiallyWritten"'); + expect(findFunction(browserSource, "onCreateKeyDialogOpenChange").getText()).toContain("if (!open && creatingKey.value) return;"); + expect(findFunction(browserSource, "onCreateKeyTypeChange").getText()).toContain("if (creatingKey.value || createKeyPartiallyWritten.value) return;"); + }); + + it("defaults TTL editing by server state, supports absolute prefill, and keeps teleported controls inside the editor", () => { + const start = findFunction(viewerSource, "startEditTtl").getText(); + const save = findFunction(viewerSource, "saveTtl").getText(); + const currentTtl = findFunction(viewerSource, "currentEditableTtl").getText(); + + expect(start).toContain("redisExpiryModeForTtl(ttl)"); + expect(currentTtl).toContain("computeTtlForExpiryEdit(autoRefreshEnabled.value, countdownTtl.value, data.value.ttl)"); + expect(viewerSource).toContain("unixSecondsToCalendarDateTime(Math.ceil(Date.now() / 1_000) + ttl)"); + expect(save).toContain("validateRedisExpiry"); + expect(save).toContain("applyRedisExpiryPolicy"); + expect(save).toContain("toast(errorMessage(error), 3000)"); + expect(viewerSource).toContain('"[data-date-time-picker-content]"'); + expect(viewerSource).toContain('"[data-redis-expiry-mode-content]"'); + expect(viewerSource).toContain('v-model="ttlExpireAt" compact'); + expect(viewerSource.match(/as="button"/g) ?? []).toHaveLength(2); + expect(viewerSource).toContain(":aria-label=\"t('redis.expiry')\""); + expect(viewerSource).toContain(":aria-label=\"t('grid.save')\""); + }); + + it("moves keyboard focus to the active TTL editor control", () => { + const start = findFunction(viewerSource, "startEditTtl").getText(); + const focus = findFunction(viewerSource, "focusTtlExpiryControl").getText(); + + expect(start).toContain("focusTtlExpiryControl()"); + expect(focus).toContain('mode === "ttl"'); + expect(focus).toContain("ttlInputEl.value?.$el?.focus()"); + expect(focus).toContain("[data-slot='select-trigger']"); + expect(focus).toContain("[data-date-time-picker-trigger]"); + expect(viewerSource).toContain("if (editingTtl.value && mode !== previousMode) focusTtlExpiryControl(mode);"); + }); + + it("serializes expiry saves and preserves unrelated value drafts", () => { + const save = findFunction(viewerSource, "saveTtl").getText(); + const load = findFunction(viewerSource, "load").getText(); + + expect(save).toContain("if (savingTtl.value) return;"); + expect(save).toContain("savingTtl.value = true;"); + expect(save).toContain("await refreshTtlState(error);"); + expect(save).toContain("const refreshError = await refreshTtlState();"); + expect(save).toContain("savingTtl.value = false;"); + expect(load).toContain("const preservedValue = { ...currentValue, ttl: loadedValue.ttl };"); + expect(viewerSource).toContain(':disabled="savingTtl"'); + }); + + it("keeps expiry controls compact and inline in the value header", () => { + expect(viewerSource).toContain("flex min-w-0 max-w-full flex-wrap items-center gap-1"); + expect(viewerSource).toContain('SelectTrigger size="sm"'); + expect(viewerSource).toContain("max-w-[min(100%,14rem)]"); + expect(viewerSource).toContain('v-model="ttlExpireAt" compact'); + expect(viewerSource).not.toContain("full-width"); + expect(viewerSource).not.toContain("basis-full"); + expect(viewerSource).not.toContain("redis-expiry-mode-trigger"); + expect(viewerSource).not.toContain('class="h-6 w-30 text-xs"'); + }); + + it("uses the positive-seconds TTL placeholder in both expiration editors", () => { + const obsoletePlaceholders = { + en: "empty = no expiry", + es: "vacío = sin expiración", + it: "vuoto = senza scadenza", + ja: "空 = 期限なし", + "pt-BR": "vazio = sem expiração", + "zh-CN": "留空表示永不过期", + "zh-TW": "留空表示永不過期", + }; + + for (const [locale, source] of Object.entries(localeSources)) { + const ttlPlaceholder = source.match(/createKeyTtlPlaceholder: "([^"]+)"/)?.[1]; + + expect(ttlPlaceholder, locale).toBeDefined(); + expect(source, locale).toContain("createKeyPartialWrite:"); + expect(ttlPlaceholder, locale).not.toBe(obsoletePlaceholders[locale as keyof typeof obsoletePlaceholders]); + } + expect(browserSource).toContain("t('redis.createKeyTtlPlaceholder')"); + expect(viewerSource).toContain("t('redis.createKeyTtlPlaceholder')"); + }); + + it("removes a key that expires while its detail view is refreshing", () => { + const load = findFunction(viewerSource, "load").getText(); + const deleted = findFunction(browserSource, "onKeyDeleted").getText(); + const loaded = findFunction(browserSource, "onKeyLoaded").getText(); + + expect(load).toContain('if (loadedValue.redis_type === "none")'); + expect(load).toContain('emit("deleted", props.keyRaw)'); + expect(deleted).toContain("removeKnownKey(keyRaw)"); + expect(loaded).toContain('if (value.redis_type === "none")'); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts b/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts index c787da664..f0833660c 100644 --- a/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts +++ b/apps/desktop/src/lib/__tests__/redis/redisAutoRefresh.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { computeAutoRefreshTick, computeDisplayTtl, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh"; +import { computeAutoRefreshTick, computeDisplayTtl, computeTtlForExpiryEdit, shouldStopAutoRefresh } from "@/lib/redis/redisAutoRefresh"; describe("computeAutoRefreshTick", () => { it("returns idle when auto-refresh is disabled", () => { @@ -64,3 +64,17 @@ describe("computeDisplayTtl", () => { expect(computeDisplayTtl(true, -1, 10)).toBe(0); }); }); + +describe("computeTtlForExpiryEdit", () => { + it("keeps a last-confirmed positive TTL during the in-flight zero-countdown window", () => { + expect(computeTtlForExpiryEdit(true, 0, 5)).toBe(5); + expect(computeTtlForExpiryEdit(true, -1, 5)).toBe(5); + }); + + it("uses the live positive countdown and preserves non-expiring server states", () => { + expect(computeTtlForExpiryEdit(true, 3, 10)).toBe(3); + expect(computeTtlForExpiryEdit(false, 3, 10)).toBe(10); + expect(computeTtlForExpiryEdit(true, 0, -1)).toBe(-1); + expect(computeTtlForExpiryEdit(true, 0, -2)).toBe(-2); + }); +}); diff --git a/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts b/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts new file mode 100644 index 000000000..dd6d00aa1 --- /dev/null +++ b/apps/desktop/src/lib/backend/__tests__/redisExpiryApi.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn(), +})); + +describe("Redis expiry Tauri API", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it("invokes EXPIREAT with the Unix timestamp", async () => { + mocks.invoke.mockResolvedValue(undefined); + const { redisSetExpireAt } = await import("@/lib/backend/tauri"); + + await redisSetExpireAt("redis-1", 2, "c2Vzc2lvbg==", 1_735_689_600); + + expect(mocks.invoke).toHaveBeenCalledWith("redis_set_expire_at", { + connectionId: "redis-1", + db: 2, + keyRaw: "c2Vzc2lvbg==", + expireAt: 1_735_689_600, + }); + }); + + it("forwards relative TTL and persist values unchanged", async () => { + mocks.invoke.mockResolvedValue(undefined); + const { redisSetTtl } = await import("@/lib/backend/tauri"); + + await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", 90); + await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", -1); + + expect(mocks.invoke).toHaveBeenNthCalledWith(1, "redis_set_ttl", { + connectionId: "redis-1", + db: 2, + keyRaw: "c2Vzc2lvbg==", + ttl: 90, + }); + expect(mocks.invoke).toHaveBeenNthCalledWith(2, "redis_set_ttl", { + connectionId: "redis-1", + db: 2, + keyRaw: "c2Vzc2lvbg==", + ttl: -1, + }); + }); +}); + +describe("Redis expiry HTTP API", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(undefined), + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + function lastCall(fetchMock: ReturnType): { url: string; body: Record } { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + return { url, body: JSON.parse(String(init.body)) as Record }; + } + + it("posts relative TTL, persist, and absolute expiration requests", async () => { + const fetchMock = stubFetch(); + const { redisSetExpireAt, redisSetTtl } = await import("@/lib/backend/http"); + + await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", 90); + expect(lastCall(fetchMock)).toEqual({ + url: "/api/redis/set-ttl", + body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", ttl: 90 }, + }); + + await redisSetTtl("redis-1", 2, "c2Vzc2lvbg==", -1); + expect(lastCall(fetchMock)).toEqual({ + url: "/api/redis/set-ttl", + body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", ttl: -1 }, + }); + + await redisSetExpireAt("redis-1", 2, "c2Vzc2lvbg==", 1_735_689_600); + expect(lastCall(fetchMock)).toEqual({ + url: "/api/redis/set-expire-at", + body: { connectionId: "redis-1", db: 2, keyRaw: "c2Vzc2lvbg==", expireAt: 1_735_689_600 }, + }); + }); +}); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 9cbfc5c03..cd3e306d8 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -395,6 +395,7 @@ export const redisStreamAdd = forward("redisStreamAdd"); export const redisJsonSet = forward("redisJsonSet"); export const redisCheckJsonModule = forward("redisCheckJsonModule"); export const redisSetTtl = forward("redisSetTtl"); +export const redisSetExpireAt = forward("redisSetExpireAt"); export const redisDeleteKeys = forward("redisDeleteKeys"); export const redisFlushDb = forward("redisFlushDb"); export const redisExecuteCommand = forward("redisExecuteCommand"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 7d41bc7cd..0938a8850 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2012,6 +2012,10 @@ export async function redisSetTtl(connectionId: string, db: number, keyRaw: stri return post("/api/redis/set-ttl", { connectionId, db, keyRaw, ttl }); } +export async function redisSetExpireAt(connectionId: string, db: number, keyRaw: string, expireAt: number): Promise { + return post("/api/redis/set-expire-at", { connectionId, db, keyRaw, expireAt }); +} + export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise { return post("/api/redis/delete-keys", { connectionId, db, keyRaws }); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index f01fc2a52..a2aa82f6f 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -1731,6 +1731,10 @@ export async function redisSetTtl(connectionId: string, db: number, keyRaw: stri return invoke("redis_set_ttl", { connectionId, db, keyRaw, ttl }); } +export async function redisSetExpireAt(connectionId: string, db: number, keyRaw: string, expireAt: number): Promise { + return invoke("redis_set_expire_at", { connectionId, db, keyRaw, expireAt }); +} + export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise { return invoke("redis_delete_keys", { connectionId, db, keyRaws }); } diff --git a/apps/desktop/src/lib/redis/redisAutoRefresh.ts b/apps/desktop/src/lib/redis/redisAutoRefresh.ts index ae8eff91f..bf508d928 100644 --- a/apps/desktop/src/lib/redis/redisAutoRefresh.ts +++ b/apps/desktop/src/lib/redis/redisAutoRefresh.ts @@ -41,3 +41,16 @@ export function shouldStopAutoRefresh(ttl: number): boolean { export function computeDisplayTtl(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number { return autoRefreshEnabled ? Math.max(countdownTtl, 0) : serverTtl; } + +/** + * Choose the TTL used to initialize an expiry edit. + * + * A zero countdown can be a transient state while the final background refresh + * is still in flight. Do not turn a last-confirmed positive Redis TTL into + * PERSIST during that window. + */ +export function computeTtlForExpiryEdit(autoRefreshEnabled: boolean, countdownTtl: number, serverTtl: number): number { + if (serverTtl <= 0) return serverTtl; + const displayedTtl = computeDisplayTtl(autoRefreshEnabled, countdownTtl, serverTtl); + return displayedTtl > 0 ? displayedTtl : serverTtl; +} diff --git a/apps/desktop/src/lib/redis/redisExpiry.spec.ts b/apps/desktop/src/lib/redis/redisExpiry.spec.ts new file mode 100644 index 000000000..81d36deb5 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisExpiry.spec.ts @@ -0,0 +1,73 @@ +import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { calendarDateTimeToUnixSeconds, formatLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "@/components/ui/date-time-picker/dateTimePicker"; +import { applyRedisExpiryPolicy, parseRedisTtl, redisExpiryModeForTtl, validateRedisExpiry } from "./redisExpiry"; + +afterEach(() => { + resetLocalTimeZone(); +}); + +describe("Redis expiry helpers", () => { + it("only accepts safe positive whole-second TTLs", () => { + expect(parseRedisTtl("")).toBeNull(); + expect(parseRedisTtl("0")).toBeNull(); + expect(parseRedisTtl("-1")).toBeNull(); + expect(parseRedisTtl("1.5")).toBeNull(); + expect(parseRedisTtl("1seconds")).toBeNull(); + expect(parseRedisTtl(String(Number.MAX_SAFE_INTEGER + 1))).toBeNull(); + expect(parseRedisTtl(" 60 ")).toBe(60); + }); + + it("maps Redis TTL metadata to the expected initial mode", () => { + expect(redisExpiryModeForTtl(-2)).toBe("none"); + expect(redisExpiryModeForTtl(-1)).toBe("none"); + expect(redisExpiryModeForTtl(1)).toBe("ttl"); + }); + + it("validates empty, invalid, past, and future absolute expiry times", () => { + const now = new Date(2024, 1, 29, 12, 0, 0).getTime(); + const future = new CalendarDateTime(2024, 2, 29, 12, 0, 1); + const past = new CalendarDateTime(2024, 2, 29, 11, 59, 59); + + expect(validateRedisExpiry("none", "", null, now)).toEqual({ valid: true, policy: { mode: "none" } }); + expect(validateRedisExpiry("ttl", "0", null, now)).toEqual({ valid: false, reason: "ttl" }); + expect(validateRedisExpiry("at", "", null, now)).toEqual({ valid: false, reason: "date" }); + expect(validateRedisExpiry("at", "", past, now)).toEqual({ valid: false, reason: "past" }); + expect(validateRedisExpiry("at", "", future, now)).toEqual({ + valid: true, + policy: { mode: "at", expireAt: calendarDateTimeToUnixSeconds(future) }, + }); + }); + + it("treats DST gaps and overlaps as invalid absolute expiry times", () => { + setLocalTimeZone("America/Los_Angeles"); + const now = new Date("2024-01-01T00:00:00Z").getTime(); + + expect(validateRedisExpiry("at", "", new CalendarDateTime(2024, 3, 10, 2, 30, 0), now)).toEqual({ valid: false, reason: "date" }); + expect(validateRedisExpiry("at", "", new CalendarDateTime(2024, 11, 3, 1, 30, 0), now)).toEqual({ valid: false, reason: "date" }); + }); + + it("keeps local calendar fields when parsing, formatting, and converting", () => { + const leap = parseLocalDateTime("2024-02-29 23:45:06"); + expect(leap).not.toBeNull(); + expect(formatLocalDateTime(leap!)).toBe("2024-02-29 23:45:06"); + expect(parseLocalDateTime("2024-02-30 23:45:06")).toBeNull(); + expect(parseLocalDateTime("2024-02-29T23:45:06")?.toString()).toBe("2024-02-29T23:45:06"); + + const roundTrip = unixSecondsToCalendarDateTime(calendarDateTimeToUnixSeconds(leap!)); + expect(formatLocalDateTime(roundTrip)).toBe("2024-02-29 23:45:06"); + }); + + it("uses PERSIST, EXPIRE, or EXPIREAT once according to the selected policy", async () => { + const transport = { setTtl: vi.fn().mockResolvedValue(undefined), setExpireAt: vi.fn().mockResolvedValue(undefined) }; + + await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "none" }); + await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "ttl", ttl: 45 }); + await applyRedisExpiryPolicy(transport, "connection", 3, "key", { mode: "at", expireAt: 1_735_689_600 }); + + expect(transport.setTtl).toHaveBeenNthCalledWith(1, "connection", 3, "key", -1); + expect(transport.setTtl).toHaveBeenNthCalledWith(2, "connection", 3, "key", 45); + expect(transport.setExpireAt).toHaveBeenCalledOnce(); + expect(transport.setExpireAt).toHaveBeenCalledWith("connection", 3, "key", 1_735_689_600); + }); +}); diff --git a/apps/desktop/src/lib/redis/redisExpiry.ts b/apps/desktop/src/lib/redis/redisExpiry.ts new file mode 100644 index 000000000..a7d048435 --- /dev/null +++ b/apps/desktop/src/lib/redis/redisExpiry.ts @@ -0,0 +1,57 @@ +import type { CalendarDateTime } from "@internationalized/date"; +import { calendarDateTimeToUnixSeconds } from "@/components/ui/date-time-picker/dateTimePicker"; + +export type RedisExpiryMode = "none" | "ttl" | "at"; + +export type RedisExpiryPolicy = { mode: "none" } | { mode: "ttl"; ttl: number } | { mode: "at"; expireAt: number }; + +export type RedisExpiryValidation = { valid: true; policy: RedisExpiryPolicy } | { valid: false; reason: "ttl" | "date" | "past" }; + +export interface RedisExpiryTransport { + setTtl: (connectionId: string, db: number, keyRaw: string, ttl: number) => Promise; + setExpireAt: (connectionId: string, db: number, keyRaw: string, expireAt: number) => Promise; +} + +/** Parse the EXPIRE argument without accepting partial, negative, or unsafe values. */ +export function parseRedisTtl(value: string): number | null { + const trimmed = value.trim(); + if (!/^[1-9]\d*$/.test(trimmed)) return null; + + const ttl = Number(trimmed); + return Number.isSafeInteger(ttl) ? ttl : null; +} + +export function redisExpiryModeForTtl(ttl: number): RedisExpiryMode { + return ttl > 0 ? "ttl" : "none"; +} + +export function validateRedisExpiry(mode: RedisExpiryMode, ttlInput: string, expireAt: CalendarDateTime | null, now = Date.now()): RedisExpiryValidation { + if (mode === "none") return { valid: true, policy: { mode } }; + + if (mode === "ttl") { + const ttl = parseRedisTtl(ttlInput); + return ttl === null ? { valid: false, reason: "ttl" } : { valid: true, policy: { mode, ttl } }; + } + + if (!expireAt) return { valid: false, reason: "date" }; + let timestamp: number; + try { + timestamp = calendarDateTimeToUnixSeconds(expireAt); + } catch { + return { valid: false, reason: "date" }; + } + return timestamp * 1_000 <= now ? { valid: false, reason: "past" } : { valid: true, policy: { mode, expireAt: timestamp } }; +} + +/** Applies exactly one post-write Redis expiration command for a validated policy. */ +export async function applyRedisExpiryPolicy(transport: RedisExpiryTransport, connectionId: string, db: number, keyRaw: string, policy: RedisExpiryPolicy): Promise { + if (policy.mode === "none") { + await transport.setTtl(connectionId, db, keyRaw, -1); + return; + } + if (policy.mode === "ttl") { + await transport.setTtl(connectionId, db, keyRaw, policy.ttl); + return; + } + await transport.setExpireAt(connectionId, db, keyRaw, policy.expireAt); +} diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index c44893493..31573ac49 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -2689,12 +2689,34 @@ where C: ConnectionLike + Send + Sync + Unpin, { if ttl > 0 { - redis::cmd("EXPIRE").arg(key).arg(ttl).query_async::<()>(con).await.map_err(|e| e.to_string()) + let applied = + redis::cmd("EXPIRE").arg(key).arg(ttl).query_async::(con).await.map_err(|e| e.to_string())?; + if applied == 1 { + Ok(()) + } else { + Err("Redis key no longer exists; EXPIRE was not applied".to_string()) + } } else { + // Redis treats PERSIST as idempotent: a zero reply also means the key + // was already persistent. Follow-up reads in the UI handle a key that + // disappeared concurrently without requiring an extra ACL permission. redis::cmd("PERSIST").arg(key).query_async::<()>(con).await.map_err(|e| e.to_string()) } } +pub async fn set_expire_at(con: &mut C, key: &[u8], expire_at: i64) -> Result<(), String> +where + C: ConnectionLike + Send + Sync + Unpin, +{ + let applied = + redis::cmd("EXPIREAT").arg(key).arg(expire_at).query_async::(con).await.map_err(|e| e.to_string())?; + if applied == 1 { + Ok(()) + } else { + Err("Redis key no longer exists; EXPIREAT was not applied".to_string()) + } +} + pub async fn delete_keys(con: &mut C, keys: &[Vec]) -> Result where C: ConnectionLike + Send + Sync + Unpin, @@ -3200,6 +3222,48 @@ mod tests { assert!(!con.commands[0].contains("\r\nEXPIRE\r\n")); } + #[tokio::test] + async fn set_expire_at_uses_expireat_with_the_unix_timestamp() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(1)]); + + super::set_expire_at(&mut con, b"session", 1_735_689_600).await.unwrap(); + + assert_eq!(con.commands.len(), 1); + assert!(con.commands[0].contains("\r\nEXPIREAT\r\n")); + assert!(con.commands[0].contains("\r\n1735689600\r\n")); + assert_eq!(con.command_count("EVAL"), 0); + } + + #[tokio::test] + async fn set_expire_at_reports_when_the_key_disappears_before_expiration_is_applied() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]); + + let error = super::set_expire_at(&mut con, b"session", 1_735_689_600).await.unwrap_err(); + + assert!(error.contains("EXPIREAT was not applied")); + } + + #[tokio::test] + async fn set_ttl_reports_when_positive_expiration_is_not_applied() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]); + + let error = super::set_ttl(&mut con, b"session", 60).await.unwrap_err(); + + assert!(error.contains("EXPIRE was not applied")); + } + + #[tokio::test] + async fn persist_is_idempotent_for_an_already_persistent_key() { + let mut con = FakeRedisConnection::new(vec![RedisRawValue::Int(0)]); + + super::set_ttl(&mut con, b"session", 0).await.unwrap(); + + assert_eq!(con.commands.len(), 1); + assert_eq!(con.command_count("PERSIST"), 1); + assert_eq!(con.command_count("EXISTS"), 0); + assert_eq!(con.command_count("EVAL"), 0); + } + #[tokio::test] async fn set_string_falls_back_to_pttl_and_px_when_keepttl_is_unsupported() { let unsupported = redis::RedisError::from(( diff --git a/crates/dbx-core/src/redis_ops.rs b/crates/dbx-core/src/redis_ops.rs index b257c253b..6cc028d13 100644 --- a/crates/dbx-core/src/redis_ops.rs +++ b/crates/dbx-core/src/redis_ops.rs @@ -645,6 +645,35 @@ pub async fn redis_set_ttl_in_db_core( } } +pub async fn redis_set_expire_at_in_db_core( + state: &AppState, + connection_id: &str, + db: u32, + key_raw: &str, + expire_at: i64, +) -> Result<(), String> { + ensure_redis_pool(state, connection_id).await?; + let connections = state.connections.read().await; + match connections.get(connection_id).ok_or("Not found")? { + PoolKind::Redis(redis) => { + let key = redis_driver::redis_key_raw_to_bytes(key_raw)?; + match redis { + RedisConnection::Direct(con) => { + let mut con = con.lock().await; + redis_driver::select_db(&mut *con, db).await?; + redis_driver::set_expire_at(&mut *con, &key, expire_at).await + } + RedisConnection::Cluster(cluster) => { + redis_driver::ensure_cluster_db(db)?; + let mut con = redis_driver::cluster_key_connection(cluster, &key).await?; + redis_driver::set_expire_at(&mut con, &key, expire_at).await + } + } + } + _ => Err("Not a Redis connection".to_string()), + } +} + pub async fn redis_delete_keys_in_db_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index f1830bc3b..241709175 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -435,6 +435,8 @@ async fn main() { .route("/redis/stream-add", post(routes::redis::stream_add)) .route("/redis/json-set", post(routes::redis::json_set)) .route("/redis/check-json-module", post(routes::redis::check_json_module)) + .route("/redis/set-ttl", post(routes::redis::set_ttl)) + .route("/redis/set-expire-at", post(routes::redis::set_expire_at)) .route("/redis/delete-keys", post(routes::redis::delete_keys)) .route("/redis/flush-db", post(routes::redis::flush_db)) .route("/redis/execute-command", post(routes::redis::execute_command)) diff --git a/crates/dbx-web/src/routes/redis.rs b/crates/dbx-web/src/routes/redis.rs index a66b741c8..7179452d7 100644 --- a/crates/dbx-web/src/routes/redis.rs +++ b/crates/dbx-web/src/routes/redis.rs @@ -162,6 +162,24 @@ pub struct RedisJsonSetRequest { pub ttl: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisSetTtlRequest { + pub connection_id: String, + pub db: u32, + pub key_raw: String, + pub ttl: i64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedisSetExpireAtRequest { + pub connection_id: String, + pub db: u32, + pub key_raw: String, + pub expire_at: i64, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RedisKeysRequest { @@ -437,6 +455,7 @@ pub async fn set_remove( } pub async fn zadd(State(state): State>, Json(req): Json) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "ZADD").await?; dbx_core::redis_ops::redis_zadd_in_db_core( &state.app, &req.connection_id, @@ -455,6 +474,7 @@ pub async fn stream_add( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "XADD").await?; dbx_core::redis_ops::redis_stream_add_in_db_core( &state.app, &req.connection_id, @@ -473,6 +493,7 @@ pub async fn json_set( State(state): State>, Json(req): Json, ) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "JSON.SET").await?; dbx_core::redis_ops::redis_json_set_in_db_core( &state.app, &req.connection_id, @@ -496,6 +517,34 @@ pub async fn check_json_module( Ok(Json(result)) } +pub async fn set_ttl( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "EXPIRE").await?; + dbx_core::redis_ops::redis_set_ttl_in_db_core(&state.app, &req.connection_id, req.db, &req.key_raw, req.ttl) + .await + .map_err(AppError::from)?; + Ok(Json(())) +} + +pub async fn set_expire_at( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + ensure_writable(&state.app, &req.connection_id, "EXPIREAT").await?; + dbx_core::redis_ops::redis_set_expire_at_in_db_core( + &state.app, + &req.connection_id, + req.db, + &req.key_raw, + req.expire_at, + ) + .await + .map_err(AppError::from)?; + Ok(Json(())) +} + pub async fn delete_keys( State(state): State>, Json(req): Json, diff --git a/package.json b/package.json index f5f49b0d6..1687c9c8c 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.43.0", + "@internationalized/date": "^3.12.2", "@lezer/highlight": "^1.2.3", "@lucide/vue": "1.17.0", "@msgpack/msgpack": "^3.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 291210d17..84183529f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@dbx-app/mongo-shell': specifier: workspace:^ version: link:packages/mongo-shell + '@internationalized/date': + specifier: ^3.12.2 + version: 3.12.2 '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 diff --git a/src-tauri/src/commands/redis_cmd.rs b/src-tauri/src/commands/redis_cmd.rs index 60da546fc..91348fce5 100644 --- a/src-tauri/src/commands/redis_cmd.rs +++ b/src-tauri/src/commands/redis_cmd.rs @@ -235,6 +235,7 @@ pub async fn redis_stream_add( fields: Vec<(String, String)>, ttl: Option, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "XADD").await?; dbx_core::redis_ops::redis_stream_add_in_db_core(&state, &connection_id, db, &key_raw, &entry_id, fields, ttl).await } @@ -247,6 +248,7 @@ pub async fn redis_json_set( value: String, ttl: Option, ) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "JSON.SET").await?; dbx_core::redis_ops::redis_json_set_in_db_core(&state, &connection_id, db, &key_raw, &value, ttl).await } @@ -271,6 +273,18 @@ pub async fn redis_set_ttl( dbx_core::redis_ops::redis_set_ttl_in_db_core(&state, &connection_id, db, &key_raw, ttl).await } +#[tauri::command] +pub async fn redis_set_expire_at( + state: State<'_, Arc>, + connection_id: String, + db: u32, + key_raw: String, + expire_at: i64, +) -> Result<(), String> { + ensure_connection_writable(&state, &connection_id, "EXPIREAT").await?; + dbx_core::redis_ops::redis_set_expire_at_in_db_core(&state, &connection_id, db, &key_raw, expire_at).await +} + #[tauri::command] pub async fn redis_delete_keys( state: State<'_, Arc>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 494f3480c..9ef2aa69c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1535,6 +1535,7 @@ pub fn run() { commands::redis_cmd::redis_json_set, commands::redis_cmd::redis_check_json_module, commands::redis_cmd::redis_set_ttl, + commands::redis_cmd::redis_set_expire_at, commands::redis_cmd::redis_delete_keys, commands::redis_cmd::redis_flush_db, commands::redis_cmd::redis_execute_command,