khotan-data 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,28 +98,39 @@ export const myPlug = plug({
98
98
 
99
99
  Endpoints power the plug debugger UI, `khotan plug --compare`, and typed clients.
100
100
 
101
- ## Typed Client (Contract Pattern)
101
+ ## Preferred Pattern
102
102
 
103
- For separate contract definition + type-safe calls:
103
+ Keep each integration in a single app-owned plug file when possible:
104
104
 
105
105
  ```typescript
106
- import { defineContract, createPlugClient } from "khotan-data/plug";
107
-
108
- const contract = defineContract({
109
- listProducts: {
110
- method: "GET",
111
- path: "/products",
112
- query: z.object({ page: z.number().optional() }),
113
- responses: { 200: z.object({ data: z.array(ProductSchema), total: z.number() }) },
114
- },
106
+ import { z } from "zod";
107
+ import { plug, basic } from "./plug";
108
+
109
+ const ProductSchema = z.object({
110
+ id: z.string(),
111
+ sku: z.string(),
112
+ name: z.string(),
115
113
  });
116
114
 
117
- const client = createPlugClient(contract, myPlug);
118
- const result = await client.listProducts({ query: { page: 1 } });
119
- // result.status 200
120
- // result.body.data — typed as Product[]
115
+ export type Product = z.infer<typeof ProductSchema>;
116
+
117
+ export const myPlug = plug({
118
+ name: "my-service",
119
+ baseUrl: "https://api.example.com",
120
+ auth: basic(process.env.API_USER!, process.env.API_KEY!),
121
+ endpoints: {
122
+ listProducts: {
123
+ method: "GET",
124
+ path: "/products",
125
+ query: z.object({ page: z.number().optional(), limit: z.number().optional() }),
126
+ responses: { 200: z.array(ProductSchema) },
127
+ },
128
+ },
129
+ });
121
130
  ```
122
131
 
132
+ This keeps the runtime plug, debugger metadata, `khotan plug --compare`, and any exported types in one place.
133
+
123
134
  ## Hooks
124
135
 
125
136
  ```typescript
@@ -179,6 +190,18 @@ npx khotan plug myPlug --endpoint listProducts --compare # Check schema
179
190
 
180
191
  Set `KHOTAN_DEBUG=1` for verbose `[khotan:auth]` and `[khotan:request]` console logs.
181
192
 
193
+ ### Recommended Plug Workflow
194
+
195
+ 1. Create the plug file and auth/hook setup.
196
+ 2. Add a small set of typed endpoints directly on the plug (`listProducts`, `getProduct`, etc).
197
+ 3. Run the app with `KHOTAN_DEBUG=1`.
198
+ 4. Use `npx khotan plug myPlug --info` to confirm the endpoints are visible to the debugger.
199
+ 5. Use `npx khotan plug myPlug --endpoint listProducts --compare` against the live API.
200
+ 6. Tighten schemas until the compare output matches the real payload shape you care about.
201
+ 7. Only then build inflows, relays, outflows, or webhook handlers on top of those endpoints.
202
+
203
+ The package does not paginate or delta-sync for you automatically inside user flows. Your app code decides which typed endpoints to call, what page size to use, when to stop, and how to implement full, test, partial, backfill, reconcile, or delta runs.
204
+
182
205
  ## Managing Vars
183
206
 
184
207
  Use the CLI to inspect and update stored plug variables:
@@ -42,8 +42,8 @@ import { stripeChargesInflow } from "./flows/stripe-charges";
42
42
  const khotanData = khotan({
43
43
  adapter: drizzleAdapter(db),
44
44
  resources: [
45
- { name: "products", connectField: "sku" },
46
- { name: "orders", connectField: "order_number" },
45
+ { name: "products", mapping: { connectField: "sku" } },
46
+ { name: "orders", mapping: { connectField: "order_number" } },
47
47
  ],
48
48
  plugs: [
49
49
  {
@@ -90,8 +90,44 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
90
90
  |----------|----------|---------|
91
91
  | `DATABASE_URL` | Yes | Postgres connection (used by Drizzle) |
92
92
  | `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
93
- | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
93
+ | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias). Automatically disabled when `NODE_ENV=production` |
94
94
  | `KHOTAN_WEBHOOK_URL` | For webhooks | Public URL for wire callbacks |
95
+ | `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route. The route fails closed in production when this is unset |
96
+
97
+ ## Securing the Management API
98
+
99
+ The management API (`/api/khotan/*`) and the Hub dashboard expose plug
100
+ credentials and operational controls. **They are public unless you gate them.**
101
+
102
+ Pass an `authorize` hook to `khotan({ ... })`. It receives the raw `Request` and
103
+ returns `true` to allow the request or `false` to reject it with `401`. It
104
+ composes directly with session libraries like better-auth:
105
+
106
+ ```typescript
107
+ import { khotan, drizzleAdapter } from "khotan-data/factory";
108
+ import { auth } from "@/lib/auth";
109
+ import { db } from "@/db";
110
+
111
+ const khotanData = khotan({
112
+ adapter: drizzleAdapter(db),
113
+ authorize: async (request) => {
114
+ const session = await auth.api.getSession({ headers: request.headers });
115
+ return Boolean(session?.user); // or: session?.user?.role === "admin"
116
+ },
117
+ plugs: [/* ... */],
118
+ });
119
+ ```
120
+
121
+ Notes:
122
+ - `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
123
+ encrypts credentials at rest, it does not authenticate requests.
124
+ - Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
125
+ the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
126
+ non-production only) are exempt from `authorize` automatically.
127
+ - Also protect the Hub dashboard page (e.g. `/config`) with your app's
128
+ middleware — `authorize` only guards the API, not your React pages.
129
+ - Without `authorize`, khotan logs a startup warning. Always configure it
130
+ before deploying.
95
131
 
96
132
  ## Next.js Config
97
133
 
@@ -111,6 +147,47 @@ curl http://localhost:3000/api/khotan/flows # Should list flows
111
147
  curl http://localhost:3000/api/khotan/resources # Should list resources
112
148
  ```
113
149
 
150
+ ## Scheduled Flows On Vercel
151
+
152
+ Khotan flow `schedule` values are runtime source-of-truth metadata. On Vercel, prefer a single dispatcher CRON instead of defining one platform CRON per flow.
153
+
154
+ Add one entry to `vercel.json`:
155
+
156
+ ```json
157
+ {
158
+ "crons": [
159
+ { "path": "/api/khotan/cron", "schedule": "* * * * *" }
160
+ ]
161
+ }
162
+ ```
163
+
164
+ Then define schedules only on your flows in `{outputDir}/khotan.ts`:
165
+
166
+ ```typescript
167
+ {
168
+ name: "products-inflow",
169
+ type: "inflow",
170
+ schedule: "0 * * * *",
171
+ resource: "products",
172
+ }
173
+ ```
174
+
175
+ The dispatcher route evaluates which flows are due on each tick and starts them through the normal run-tracking path. If `CRON_SECRET` is set, Vercel should call the route with `Authorization: Bearer <CRON_SECRET>`.
176
+
177
+ ## Typical Build Order
178
+
179
+ After init and schema setup, the usual path to a working sync is:
180
+
181
+ 1. Add or author a plug file for the external service.
182
+ 2. Define a few typed endpoints directly on the plug with Zod response schemas.
183
+ 3. Start the app with `KHOTAN_DEBUG=1`.
184
+ 4. Verify the plug is visible with `npx khotan plug --list` and `npx khotan plug myPlug --info`.
185
+ 5. Hit live endpoints with `npx khotan plug myPlug --endpoint listProducts --compare` until the schemas match the real API shape you intend to use.
186
+ 6. Register the plug in `{outputDir}/khotan.ts` with resources and flows.
187
+ 7. Only after endpoint verification, build inflows, relays, outflows, or webhook handlers on top of those live-checked endpoints.
188
+
189
+ This keeps sync logic grounded in real API payloads before you write pagination, mapping, or transformation code.
190
+
114
191
  ## Troubleshooting
115
192
 
116
193
  - **Empty plug list**: Factory upserts on first request — hit any endpoint first, then check `/plugs`
@@ -18,6 +18,7 @@ import {
18
18
  type NodeProps,
19
19
  } from "@xyflow/react";
20
20
  import "@xyflow/react/dist/style.css";
21
+ import { khotanFetch, ApiErrorState } from "./api-state";
21
22
  import { Badge } from "@/components/ui/badge";
22
23
  import {
23
24
  Card,
@@ -1056,8 +1057,9 @@ function TopologyCanvasInner() {
1056
1057
  const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
1057
1058
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
1058
1059
  const [loading, setLoading] = useState(true);
1059
- const [error, setError] = useState<string | null>(null);
1060
+ const [error, setError] = useState<unknown>(null);
1060
1061
  const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
1062
+ const [refreshKey, setRefreshKey] = useState(0);
1061
1063
 
1062
1064
  useEffect(() => {
1063
1065
  let cancelled = false;
@@ -1068,30 +1070,22 @@ function TopologyCanvasInner() {
1068
1070
  setError(null);
1069
1071
  }
1070
1072
 
1071
- const [plugsRes, flowsRes, runsRes] = await Promise.all([
1072
- fetch("/api/khotan/plugs"),
1073
- fetch("/api/khotan/flows"),
1074
- fetch("/api/khotan/runs?limit=100"),
1073
+ const [plugs, flows, runsRaw] = await Promise.all([
1074
+ khotanFetch<PlugRecord[]>("/api/khotan/plugs"),
1075
+ khotanFetch<FlowRecord[]>("/api/khotan/flows"),
1076
+ khotanFetch<unknown>("/api/khotan/runs?limit=100"),
1075
1077
  ]);
1076
1078
 
1077
- if (!plugsRes.ok || !flowsRes.ok || !runsRes.ok) {
1078
- throw new Error("Failed to load topology data from /api/khotan");
1079
- }
1080
-
1081
- const plugs = (await plugsRes.json()) as PlugRecord[];
1082
- const flows = (await flowsRes.json()) as FlowRecord[];
1083
- const runs = normalizeRuns(await runsRes.json());
1079
+ const runs = normalizeRuns(runsRaw);
1084
1080
 
1085
1081
  const webhookGroups = await Promise.all(
1086
1082
  plugs.map(async (plug) => {
1087
1083
  try {
1088
- const res = await fetch(
1084
+ const handlers = await khotanFetch<
1085
+ Array<Omit<WebhookHandlerRecord, "plugName">>
1086
+ >(
1089
1087
  `/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
1090
1088
  );
1091
- if (!res.ok) return [] as WebhookHandlerRecord[];
1092
- const handlers = (await res.json()) as Array<
1093
- Omit<WebhookHandlerRecord, "plugName">
1094
- >;
1095
1089
  return handlers.map((handler) => ({
1096
1090
  ...handler,
1097
1091
  plugName: plug.name,
@@ -1113,11 +1107,7 @@ function TopologyCanvasInner() {
1113
1107
  setLastUpdatedAt(new Date().toISOString());
1114
1108
  } catch (loadError) {
1115
1109
  if (!cancelled) {
1116
- setError(
1117
- loadError instanceof Error
1118
- ? loadError.message
1119
- : "Unknown topology load failure",
1120
- );
1110
+ setError(loadError);
1121
1111
  }
1122
1112
  } finally {
1123
1113
  if (!cancelled) {
@@ -1135,7 +1125,7 @@ function TopologyCanvasInner() {
1135
1125
  cancelled = true;
1136
1126
  window.clearInterval(interval);
1137
1127
  };
1138
- }, []);
1128
+ }, [refreshKey]);
1139
1129
 
1140
1130
  const model = useMemo(() => {
1141
1131
  return snapshot ? buildTopologyModel(snapshot) : null;
@@ -1180,19 +1170,18 @@ function TopologyCanvasInner() {
1180
1170
 
1181
1171
  if (error) {
1182
1172
  return (
1183
- <Card className="border-red-200 bg-white/80 shadow-xl backdrop-blur">
1173
+ <Card className="border-white/70 bg-white/80 shadow-xl backdrop-blur">
1184
1174
  <CardHeader>
1185
1175
  <CardTitle>Topology Canvas</CardTitle>
1186
1176
  <CardDescription>
1187
1177
  Could not load the graph from the local Khotan API.
1188
1178
  </CardDescription>
1189
1179
  </CardHeader>
1190
- <CardContent className="space-y-3 text-sm">
1191
- <p className="text-red-600">{error}</p>
1192
- <p className="text-slate-500">
1193
- Make sure the catch-all Khotan route is mounted and your dev server
1194
- is running.
1195
- </p>
1180
+ <CardContent>
1181
+ <ApiErrorState
1182
+ error={error}
1183
+ onRetry={() => setRefreshKey((v) => v + 1)}
1184
+ />
1196
1185
  </CardContent>
1197
1186
  </Card>
1198
1187
  );
@@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { Input } from "@/components/ui/input";
8
8
  import { Label } from "@/components/ui/label";
9
+ import { khotanFetch, ApiErrorState } from "./api-state";
9
10
 
10
11
  // ============================================================================
11
12
  // Var Panel — UI for managing plug variables
@@ -44,6 +45,7 @@ export function VarPanel({
44
45
  const [configured, setConfigured] = useState(false);
45
46
  const [loading, setLoading] = useState(true);
46
47
  const [saving, setSaving] = useState(false);
48
+ const [loadError, setLoadError] = useState<unknown>(null);
47
49
  const [error, setError] = useState<string | null>(null);
48
50
  const [success, setSuccess] = useState<string | null>(null);
49
51
  const [editing, setEditing] = useState(false);
@@ -52,21 +54,23 @@ export function VarPanel({
52
54
  );
53
55
 
54
56
  const fetchVariables = useCallback(async () => {
57
+ setLoading(true);
58
+ setLoadError(null);
55
59
  try {
56
- const res = await fetch(`${basePath}/variables/${plugName}`);
57
- if (!res.ok) {
58
- setFields([]);
59
- setConfigured(false);
60
- return;
61
- }
62
- const data = await res.json();
60
+ const data = await khotanFetch<{
61
+ fields?: VarField[];
62
+ values?: Record<string, string>;
63
+ configured?: boolean;
64
+ }>(`${basePath}/variables/${plugName}`);
63
65
  setFields((data.fields ?? []).filter((f: VarField) => !f.hidden));
64
66
  setValues(data.values ?? {});
65
67
  setConfigured(data.configured ?? false);
66
- setFormValues(data.configured ? data.values : {});
68
+ setFormValues(data.configured ? (data.values ?? {}) : {});
67
69
  setError(null);
68
- } catch {
69
- setError("Failed to load variables");
70
+ } catch (err) {
71
+ setFields([]);
72
+ setConfigured(false);
73
+ setLoadError(err);
70
74
  } finally {
71
75
  setLoading(false);
72
76
  }
@@ -144,6 +148,25 @@ export function VarPanel({
144
148
  );
145
149
  }
146
150
 
151
+ if (loadError) {
152
+ return (
153
+ <Card>
154
+ <CardHeader className="pb-2">
155
+ <CardTitle className="text-sm font-medium capitalize">
156
+ {displayName} Variables
157
+ </CardTitle>
158
+ </CardHeader>
159
+ <CardContent>
160
+ <ApiErrorState
161
+ error={loadError}
162
+ onRetry={() => void fetchVariables()}
163
+ compact
164
+ />
165
+ </CardContent>
166
+ </Card>
167
+ );
168
+ }
169
+
147
170
  if (fields.length === 0) return null;
148
171
 
149
172
  return (
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useState } from "react";
4
+ import { khotanFetch, ApiErrorState } from "./api-state";
4
5
  import { Badge } from "@/components/ui/badge";
5
6
  import { Button } from "@/components/ui/button";
6
7
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -75,7 +76,7 @@ export function KhotanWebhookEventsTable({
75
76
  const [data, setData] = useState<PageResponse<WebhookEventItem> | null>(null);
76
77
  const [offset, setOffset] = useState(0);
77
78
  const [loading, setLoading] = useState(true);
78
- const [error, setError] = useState<string | null>(null);
79
+ const [error, setError] = useState<unknown>(null);
79
80
  const [refreshKey, setRefreshKey] = useState(0);
80
81
 
81
82
  useEffect(() => {
@@ -85,19 +86,15 @@ export function KhotanWebhookEventsTable({
85
86
  setLoading(true);
86
87
  setError(null);
87
88
  try {
88
- const res = await fetch(
89
+ const json = await khotanFetch<PageResponse<WebhookEventItem>>(
89
90
  `/api/khotan/webhook-events?limit=${String(pageSize)}&offset=${String(offset)}`,
90
91
  );
91
- if (!res.ok) {
92
- throw new Error("Failed to load webhook events");
93
- }
94
- const json = (await res.json()) as PageResponse<WebhookEventItem>;
95
92
  if (!cancelled) {
96
93
  setData(json);
97
94
  }
98
95
  } catch (err) {
99
96
  if (!cancelled) {
100
- setError(err instanceof Error ? err.message : "Unknown error");
97
+ setError(err);
101
98
  }
102
99
  } finally {
103
100
  if (!cancelled) {
@@ -131,110 +128,116 @@ export function KhotanWebhookEventsTable({
131
128
  </CardHeader>
132
129
  <CardContent className="space-y-4">
133
130
  {error ? (
134
- <div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
135
- {error}
136
- </div>
131
+ <ApiErrorState
132
+ error={error}
133
+ onRetry={() => setRefreshKey((v) => v + 1)}
134
+ compact
135
+ />
137
136
  ) : null}
138
137
 
139
- <Table>
140
- <TableHeader>
141
- <TableRow>
142
- <TableHead>Received</TableHead>
143
- <TableHead>Event</TableHead>
144
- <TableHead>Handler</TableHead>
145
- <TableHead>Plug</TableHead>
146
- <TableHead>Run</TableHead>
147
- <TableHead>Payload</TableHead>
148
- </TableRow>
149
- </TableHeader>
150
- <TableBody>
151
- {loading ? (
138
+ {error ? null : (
139
+ <Table>
140
+ <TableHeader>
152
141
  <TableRow>
153
- <TableCell
154
- colSpan={6}
155
- className="text-sm text-muted-foreground"
156
- >
157
- Loading webhook events...
158
- </TableCell>
142
+ <TableHead>Received</TableHead>
143
+ <TableHead>Event</TableHead>
144
+ <TableHead>Handler</TableHead>
145
+ <TableHead>Plug</TableHead>
146
+ <TableHead>Run</TableHead>
147
+ <TableHead>Payload</TableHead>
159
148
  </TableRow>
160
- ) : data?.items.length ? (
161
- data.items.map((item) => (
162
- <TableRow key={item.id}>
163
- <TableCell className="text-sm text-muted-foreground">
164
- {formatDateTime(item.receivedAt)}
165
- </TableCell>
166
- <TableCell className="font-medium">
167
- {item.eventType}
168
- </TableCell>
169
- <TableCell className="text-sm text-muted-foreground">
170
- {formatHandler(item)}
171
- </TableCell>
172
- <TableCell className="text-muted-foreground">
173
- {item.plugName ?? "-"}
174
- </TableCell>
175
- <TableCell className="space-y-1 text-xs">
176
- <div className="font-mono text-muted-foreground">
177
- {item.khotanRunId}
178
- </div>
179
- <div className="flex flex-wrap items-center gap-2">
180
- {item.runStatus ? (
181
- <Badge variant={statusVariant[item.runStatus]}>
182
- {item.runStatus}
183
- </Badge>
184
- ) : null}
185
- <span className="font-mono text-muted-foreground">
186
- {item.workflowRunId ?? "-"}
187
- </span>
188
- </div>
149
+ </TableHeader>
150
+ <TableBody>
151
+ {loading ? (
152
+ <TableRow>
153
+ <TableCell
154
+ colSpan={6}
155
+ className="text-sm text-muted-foreground"
156
+ >
157
+ Loading webhook events...
189
158
  </TableCell>
190
- <TableCell className="max-w-80">
191
- <details>
192
- <summary className="cursor-pointer text-sm text-primary">
193
- View payload
194
- </summary>
195
- <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs">
196
- {JSON.stringify(item.payload, null, 2)}
197
- </pre>
198
- </details>
159
+ </TableRow>
160
+ ) : data?.items.length ? (
161
+ data.items.map((item) => (
162
+ <TableRow key={item.id}>
163
+ <TableCell className="text-sm text-muted-foreground">
164
+ {formatDateTime(item.receivedAt)}
165
+ </TableCell>
166
+ <TableCell className="font-medium">
167
+ {item.eventType}
168
+ </TableCell>
169
+ <TableCell className="text-sm text-muted-foreground">
170
+ {formatHandler(item)}
171
+ </TableCell>
172
+ <TableCell className="text-muted-foreground">
173
+ {item.plugName ?? "-"}
174
+ </TableCell>
175
+ <TableCell className="space-y-1 text-xs">
176
+ <div className="font-mono text-muted-foreground">
177
+ {item.khotanRunId}
178
+ </div>
179
+ <div className="flex flex-wrap items-center gap-2">
180
+ {item.runStatus ? (
181
+ <Badge variant={statusVariant[item.runStatus]}>
182
+ {item.runStatus}
183
+ </Badge>
184
+ ) : null}
185
+ <span className="font-mono text-muted-foreground">
186
+ {item.workflowRunId ?? "-"}
187
+ </span>
188
+ </div>
189
+ </TableCell>
190
+ <TableCell className="max-w-80">
191
+ <details>
192
+ <summary className="cursor-pointer text-sm text-primary">
193
+ View payload
194
+ </summary>
195
+ <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-xs">
196
+ {JSON.stringify(item.payload, null, 2)}
197
+ </pre>
198
+ </details>
199
+ </TableCell>
200
+ </TableRow>
201
+ ))
202
+ ) : (
203
+ <TableRow>
204
+ <TableCell
205
+ colSpan={6}
206
+ className="text-sm text-muted-foreground"
207
+ >
208
+ No webhook events recorded yet.
199
209
  </TableCell>
200
210
  </TableRow>
201
- ))
202
- ) : (
203
- <TableRow>
204
- <TableCell
205
- colSpan={6}
206
- className="text-sm text-muted-foreground"
207
- >
208
- No webhook events recorded yet.
209
- </TableCell>
210
- </TableRow>
211
- )}
212
- </TableBody>
213
- </Table>
211
+ )}
212
+ </TableBody>
213
+ </Table>
214
+ )}
214
215
 
215
- <div className="flex items-center justify-between gap-3">
216
- <p className="text-sm text-muted-foreground">
217
- Page {Math.floor(offset / pageSize) + 1}
218
- </p>
219
- <div className="flex items-center gap-2">
220
- <Button
221
- variant="outline"
222
- size="sm"
223
- disabled={offset === 0 || loading}
224
- onClick={() => setOffset(Math.max(offset - pageSize, 0))}
225
- >
226
- Previous
227
- </Button>
228
- <Button
229
- variant="outline"
230
- size="sm"
231
- disabled={!data?.page.hasMore || loading}
232
- onClick={() => setOffset(offset + pageSize)}
233
- >
234
- Next
235
- </Button>
216
+ {error ? null : (
217
+ <div className="flex items-center justify-between gap-3">
218
+ <p className="text-sm text-muted-foreground">
219
+ Page {Math.floor(offset / pageSize) + 1}
220
+ </p>
221
+ <div className="flex items-center gap-2">
222
+ <Button
223
+ variant="outline"
224
+ size="sm"
225
+ disabled={offset === 0 || loading}
226
+ onClick={() => setOffset(Math.max(offset - pageSize, 0))}
227
+ >
228
+ Previous
229
+ </Button>
230
+ <Button
231
+ variant="outline"
232
+ size="sm"
233
+ disabled={!data?.page.hasMore || loading}
234
+ onClick={() => setOffset(offset + pageSize)}
235
+ >
236
+ Next
237
+ </Button>
238
+ </div>
236
239
  </div>
237
- </div>
240
+ )}
238
241
  </CardContent>
239
242
  </Card>
240
243
  );