Skip to content

feat: Add ttl support for hash keys #11

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 5 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@tabler/icons-react": "^3.19.0",
"@tanstack/react-query": "^5.32.0",
"@types/bytes": "^3.1.4",
"@upstash/redis": "^1.34.3",
"@upstash/redis": "^1.34.8",
"bytes": "^3.1.2",
"react-hook-form": "^7.53.0",
"react-resizable-panels": "^2.1.4",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { IconPlus } from "@tabler/icons-react"
import { Button } from "@/components/ui/button"

import { TypeTag } from "../type-tag"
import { LengthBadge, SizeBadge, TTLBadge } from "./header-badges"
import { HeaderTTLBadge, LengthBadge, SizeBadge } from "./header-badges"
import { KeyActions } from "./key-actions"

export const DisplayHeader = ({
Expand Down Expand Up @@ -49,7 +49,7 @@ export const DisplayHeader = ({
<TypeTag variant={type} type="badge" />
<SizeBadge dataKey={dataKey} />
<LengthBadge dataKey={dataKey} type={type} content={content} />
<TTLBadge dataKey={dataKey} />
<HeaderTTLBadge dataKey={dataKey} />
</div>
</div>
)
Expand Down
57 changes: 36 additions & 21 deletions src/components/databrowser/components/display/display-list-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { useDatabrowserStore } from "@/store"
import type { ListDataType } from "@/types"
import { Controller, FormProvider, useForm, useFormContext } from "react-hook-form"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { SimpleTooltip } from "@/components/ui/tooltip"

import { useFetchListItems } from "../../hooks"
import { useEditListItem } from "../../hooks/use-edit-list-item"
import { headerLabels } from "./display-list"
import { HashFieldTTLBadge } from "./hash/hash-field-ttl-badge"
import { useField } from "./input/use-field"

export const ListEditDisplay = ({
Expand Down Expand Up @@ -105,30 +107,43 @@ const ListEditForm = ({
)}
</div>

<div className="flex justify-end gap-2">
<Button
type="button"
onClick={() => {
setSelectedListItem(undefined)
}}
>
Cancel
</Button>
<SimpleTooltip
content={type === "stream" && !isNew ? "Streams are not mutable" : undefined}
>
<div
className={cn(
"flex items-center",
type === "hash" && itemKey !== "" ? "justify-between" : "justify-end"
)}
>
{type === "hash" && itemKey !== "" && (
<HashFieldTTLBadge dataKey={dataKey} field={itemKey} />
)}

<div className="flex gap-2">
<Button
variant="primary"
type="submit"
disabled={
!form.formState.isValid || !form.formState.isDirty || (type === "stream" && !isNew)
}
type="button"
onClick={() => {
setSelectedListItem(undefined)
}}
>
<Spinner isLoading={isPending} isLoadingText={"Saving"}>
Save
</Spinner>
Cancel
</Button>
</SimpleTooltip>
<SimpleTooltip
content={type === "stream" && !isNew ? "Streams are not mutable" : undefined}
>
<Button
variant="primary"
type="submit"
disabled={
!form.formState.isValid ||
!form.formState.isDirty ||
(type === "stream" && !isNew)
}
>
<Spinner isLoading={isPending} isLoadingText={"Saving"}>
Save
</Spinner>
</Button>
</SimpleTooltip>
</div>
</div>
</form>
</FormProvider>
Expand Down
51 changes: 31 additions & 20 deletions src/components/databrowser/components/display/display-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { InfiniteScroll } from "../sidebar/infinite-scroll"
import { DeleteAlertDialog } from "./delete-alert-dialog"
import { DisplayHeader } from "./display-header"
import { ListEditDisplay } from "./display-list-edit"
import { HashFieldTTLInfo } from "./hash/hash-field-ttl-info"

export const headerLabels = {
list: ["Index", "Content"],
Expand Down Expand Up @@ -72,6 +73,7 @@ export const ListItems = ({
}) => {
const { setSelectedListItem } = useDatabrowserStore()
const keys = useMemo(() => query.data?.pages.flatMap((page) => page.keys) ?? [], [query.data])
const fields = useMemo(() => keys.map((key) => key.key), [keys])
const { mutate: editItem } = useEditListItem()

return (
Expand All @@ -84,7 +86,7 @@ export const ListItems = ({
onClick={() => {
setSelectedListItem({ key })
}}
className="h-10 border-b border-b-zinc-100 hover:bg-zinc-50"
className={cn("h-10 border-b border-b-zinc-100 hover:bg-zinc-100 ")}
>
<td
className={cn(
Expand All @@ -103,29 +105,38 @@ export const ListItems = ({
)}
{type !== "stream" && (
<td
width={20}
className="px-3"
className="w-0 min-w-0 p-0"
onClick={(e) => {
e.stopPropagation()
}}
>
<DeleteAlertDialog
deletionType="item"
onDeleteConfirm={(e) => {
e.stopPropagation()
editItem({
type,
dataKey,
itemKey: key,
// For deletion
newKey: undefined,
})
}}
>
<Button size="icon-sm" variant="secondary" onClick={(e) => e.stopPropagation()}>
<IconTrash className="size-4 text-zinc-500" />
</Button>
</DeleteAlertDialog>
<div className="flex items-center justify-end gap-2">
{type === "hash" && (
<HashFieldTTLInfo dataKey={dataKey} field={key} fields={fields} />
)}
<DeleteAlertDialog
deletionType="item"
onDeleteConfirm={(e) => {
e.stopPropagation()
editItem({
type,
dataKey,
itemKey: key,
// For deletion
newKey: undefined,
})
}}
>
<Button
className=""
size="icon-sm"
variant="secondary"
onClick={(e) => e.stopPropagation()}
>
<IconTrash className="size-4 text-zinc-500" />
</Button>
</DeleteAlertDialog>
</div>
</td>
)}
</tr>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useFetchHashFieldExpires } from "@/components/databrowser/hooks/use-fetch-hash-ttl"
import { useSetHashTTL } from "@/components/databrowser/hooks/use-set-hash-ttl"

import { TTLBadge } from "../ttl-badge"

export const HashFieldTTLBadge = ({ dataKey, field }: { dataKey: string; field: string }) => {
const { data } = useFetchHashFieldExpires({ dataKey, fields: [field] })
const { mutate: setTTL, isPending } = useSetHashTTL()

const expireAt = data?.[field]

return (
<TTLBadge
label="Field TTL:"
expireAt={expireAt}
setTTL={(ttl) => setTTL({ dataKey, field, ttl })}
isPending={isPending}
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react"

import { formatTime } from "@/lib/utils"
import { useFetchHashFieldExpires } from "@/components/databrowser/hooks/use-fetch-hash-ttl"

import { calculateTTL, TTL_INFINITE, TTL_NOT_FOUND } from "../ttl-badge"

export const HashFieldTTLInfo = ({
dataKey,
field,
fields,
}: {
dataKey: string
field: string
fields: string[]
}) => {
const { data } = useFetchHashFieldExpires({ dataKey, fields })
const expireAt = data?.[field]

const [ttl, setTTL] = useState(() => calculateTTL(expireAt))

useEffect(() => {
setTTL(calculateTTL(expireAt))
const interval = setInterval(() => {
setTTL(calculateTTL(expireAt))
}, 1000)

return () => clearInterval(interval)
}, [expireAt])

if (!expireAt || expireAt === TTL_NOT_FOUND || expireAt === TTL_INFINITE) return

return (
<span className="block min-w-[30px] whitespace-nowrap text-right text-red-600">
{formatTime(ttl ?? 0)}
</span>
)
}
52 changes: 10 additions & 42 deletions src/components/databrowser/components/display/header-badges.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import { useEffect } from "react"
import { type DataType } from "@/types"
import { IconChevronDown } from "@tabler/icons-react"
import bytes from "bytes"

import { queryClient } from "@/lib/clients"
import { formatTime } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"

import { FETCH_TTL_QUERY_KEY, useFetchTTL } from "../../hooks"
import { useDeleteKeyCache } from "../../hooks/use-delete-key-cache"
import { useFetchKeyExpire, useSetTTL } from "../../hooks"
import { useFetchKeyLength } from "../../hooks/use-fetch-key-length"
import { useFetchKeySize } from "../../hooks/use-fetch-key-size"
import { TTLPopover } from "./ttl-popover"
import { TTLBadge } from "./ttl-badge"

export const LengthBadge = ({
dataKey,
Expand Down Expand Up @@ -50,43 +45,16 @@ export const SizeBadge = ({ dataKey }: { dataKey: string }) => {
)
}

const TTL_INFINITE = -1
const TTL_NOT_FOUND = -2

export const TTLBadge = ({ dataKey }: { dataKey: string }) => {
const { data: ttl } = useFetchTTL(dataKey)
const { deleteKeyCache } = useDeleteKeyCache()

// Tick the ttl query every second
useEffect(() => {
const interval = setInterval(() => {
queryClient.setQueryData([FETCH_TTL_QUERY_KEY, dataKey], (ttl?: number) => {
if (ttl === undefined || ttl === TTL_INFINITE) return ttl

if (ttl <= 1) {
deleteKeyCache(dataKey)
return TTL_NOT_FOUND
}
return ttl - 1
})
}, 1000)

return () => clearInterval(interval)
}, [])
export const HeaderTTLBadge = ({ dataKey }: { dataKey: string }) => {
const { data: expireAt } = useFetchKeyExpire(dataKey)
const { mutate: setTTL, isPending } = useSetTTL()

return (
<Badge label="TTL:">
{ttl === undefined ? (
<Skeleton className="ml-1 h-3 w-10 rounded-md opacity-50" />
) : (
<TTLPopover dataKey={dataKey} ttl={ttl}>
<div className="flex gap-[2px]">
{ttl === TTL_INFINITE ? "Forever" : formatTime(ttl)}
<IconChevronDown className="mt-[1px] text-zinc-400" size={12} />
</div>
</TTLPopover>
)}
</Badge>
<TTLBadge
expireAt={expireAt}
setTTL={(ttl) => setTTL({ dataKey, ttl })}
isPending={isPending}
/>
)
}

Expand Down
56 changes: 56 additions & 0 deletions src/components/databrowser/components/display/ttl-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react"
import { IconChevronDown } from "@tabler/icons-react"

import { formatTime } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"

import { Badge } from "./header-badges"
import { TTLPopover } from "./ttl-popover"

export const TTL_INFINITE = -1
export const TTL_NOT_FOUND = -2

export const calculateTTL = (expireAt?: number) => {
if (!expireAt) return
if (expireAt === TTL_INFINITE) return TTL_INFINITE
return Math.max(0, Math.floor((expireAt - Date.now()) / 1000))
}

export const TTLBadge = ({
label = "TTL:",
expireAt,
setTTL,
isPending,
}: {
label?: string
expireAt?: number
setTTL: (ttl: number) => void
isPending: boolean
}) => {
const [ttl, setTTLLabel] = useState(() => calculateTTL(expireAt))

// Update ttl every second
useEffect(() => {
setTTLLabel(calculateTTL(expireAt))
const interval = setInterval(() => {
setTTLLabel(calculateTTL(expireAt))
}, 1000)

return () => clearInterval(interval)
}, [expireAt])

return (
<Badge label={label}>
{ttl === undefined ? (
<Skeleton className="ml-1 h-3 w-10 rounded-md opacity-50" />
) : (
<TTLPopover ttl={ttl} setTTL={setTTL} isPending={isPending}>
<div className="flex gap-[2px]">
{ttl === TTL_INFINITE ? "Forever" : formatTime(ttl)}
<IconChevronDown className="mt-[1px] text-zinc-400" size={12} />
</div>
</TTLPopover>
)}
</Badge>
)
}
Loading