]*>/)?.[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 0000000000..1d7b2cd8b5
--- /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 8d3dfb2d51..921f433df6 100644
--- a/apps/desktop/src/components/redis/RedisKeyBrowser.vue
+++ b/apps/desktop/src/components/redis/RedisKeyBrowser.vue
@@ -1,5 +1,6 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ monthHeading }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ validationMessage }}
+
+
+
+
+
+
+
+
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 0000000000..008aca3243
--- /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 0000000000..dbc1d537b0
--- /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 0000000000..3ce4f0d68a
--- /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 2a753a929d..ca8e7a1d5c 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 f2715d2e8c..23cd847a2f 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 3d68a364d2..30e708e4cc 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 f844c91c0b..08d934cd48 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 10d38c4052..2d5fa67f87 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 ea9059310d..aa366ff419 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 c7ff6e644b..4daecd3a01 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 0000000000..97b5973f8f
--- /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 c787da6649..f0833660c1 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 0000000000..dd6d00aa1c
--- /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 9cbfc5c039..cd3e306d8b 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 7d41bc7cd6..0938a88503 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 f01fc2a524..a2aa82f6ff 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 ae8eff91fa..bf508d9281 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 0000000000..81d36deb53
--- /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 0000000000..a7d048435d
--- /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 c448934937..31573ac496 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 b257c253b0..6cc028d138 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 f1830bc3b2..241709175a 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 a66b741c8a..7179452d7c 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 f5f49b0d6c..1687c9c8c6 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 291210d174..84183529f4 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 60da546fc7..91348fce53 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 494f3480c1..9ef2aa69c4 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,