feat(redis): add local expiration modes
This commit is contained in:
1 parent
3a2dc4225a
commit
51fa058edb
35 files changed
+2918
-98
No files matched your search
@@ -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<string, number> = {
|
||||
"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")');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof stubFetch>): { url: string; body: Record<string, unknown> } {
|
||||
const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit];
|
||||
return { url, body: JSON.parse(String(init.body)) as Record<string, unknown> };
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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<void> {
|
||||
return post("/api/redis/set-expire-at", { connectionId, db, keyRaw, expireAt });
|
||||
}
|
||||
|
||||
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
|
||||
return post("/api/redis/delete-keys", { connectionId, db, keyRaws });
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
return invoke("redis_set_expire_at", { connectionId, db, keyRaw, expireAt });
|
||||
}
|
||||
|
||||
export async function redisDeleteKeys(connectionId: string, db: number, keyRaws: string[]): Promise<number> {
|
||||
return invoke("redis_delete_keys", { connectionId, db, keyRaws });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+73
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
setExpireAt: (connectionId: string, db: number, keyRaw: string, expireAt: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user