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.
@@ -0,0 +1,249 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+
5
+ // ============================================================================
6
+ // Khotan API helpers — typed fetch + graceful error/empty UI states
7
+ // Generated by khotan CLI · https://github.com/khotan-data
8
+ //
9
+ // This file is yours. Every khotan dashboard component imports `khotanFetch`
10
+ // and `<ApiErrorState>` from here so that 401 (auth), 403, 404, 5xx, and
11
+ // network failures render a simple, consistent UI instead of a blank screen
12
+ // or a thrown error. Restyle it however you like.
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Error thrown by {@link khotanFetch} when a request fails. Carries the HTTP
17
+ * status so the UI can branch on it. `status === 0` means a network-level
18
+ * failure (server unreachable) rather than an HTTP error response.
19
+ */
20
+ export class KhotanApiError extends Error {
21
+ readonly status: number;
22
+ readonly statusText: string;
23
+ readonly body: unknown;
24
+
25
+ constructor(status: number, statusText: string, body?: unknown) {
26
+ super(
27
+ status === 0
28
+ ? "Could not reach the server"
29
+ : `Request failed: ${String(status)} ${statusText}`,
30
+ );
31
+ this.name = "KhotanApiError";
32
+ this.status = status;
33
+ this.statusText = statusText;
34
+ this.body = body;
35
+ }
36
+ }
37
+
38
+ export function isKhotanApiError(error: unknown): error is KhotanApiError {
39
+ return error instanceof KhotanApiError;
40
+ }
41
+
42
+ /**
43
+ * `fetch` wrapper that throws a typed {@link KhotanApiError} on any non-2xx
44
+ * response (or network failure) and returns parsed JSON otherwise. Use it for
45
+ * every call to the khotan API so error handling stays consistent.
46
+ */
47
+ export async function khotanFetch<T = unknown>(
48
+ input: string,
49
+ init?: RequestInit,
50
+ ): Promise<T> {
51
+ let res: Response;
52
+ try {
53
+ res = await fetch(input, init);
54
+ } catch (err) {
55
+ throw new KhotanApiError(
56
+ 0,
57
+ "Network Error",
58
+ err instanceof Error ? err.message : undefined,
59
+ );
60
+ }
61
+
62
+ if (!res.ok) {
63
+ let body: unknown;
64
+ try {
65
+ body = await res.clone().json();
66
+ } catch {
67
+ try {
68
+ body = await res.text();
69
+ } catch {
70
+ body = undefined;
71
+ }
72
+ }
73
+ throw new KhotanApiError(res.status, res.statusText, body);
74
+ }
75
+
76
+ if (res.status === 204) return undefined as T;
77
+
78
+ try {
79
+ return (await res.json()) as T;
80
+ } catch {
81
+ return undefined as T;
82
+ }
83
+ }
84
+
85
+ interface ErrorCopy {
86
+ /** HTTP status code, or null for non-HTTP failures. */
87
+ code: number | null;
88
+ title: string;
89
+ message: string;
90
+ /** Auth errors (401/403) show a lock icon instead of the alert icon. */
91
+ isAuth: boolean;
92
+ }
93
+
94
+ function describeError(error: unknown): ErrorCopy {
95
+ const status = isKhotanApiError(error) ? error.status : null;
96
+
97
+ if (status === 401 || status === 403) {
98
+ return {
99
+ code: status,
100
+ title: "Access denied",
101
+ message: "You don't have permission to view this.",
102
+ isAuth: true,
103
+ };
104
+ }
105
+ if (status === 404) {
106
+ return {
107
+ code: 404,
108
+ title: "Not found",
109
+ message: "This resource doesn't exist.",
110
+ isAuth: false,
111
+ };
112
+ }
113
+ if (status === 429) {
114
+ return {
115
+ code: 429,
116
+ title: "Too many requests",
117
+ message: "Slow down and try again in a moment.",
118
+ isAuth: false,
119
+ };
120
+ }
121
+ if (status !== null && status >= 500) {
122
+ return {
123
+ code: status,
124
+ title: "Server error",
125
+ message: "Something went wrong on the server.",
126
+ isAuth: false,
127
+ };
128
+ }
129
+ if (status !== null && status >= 400) {
130
+ return {
131
+ code: status,
132
+ title: "Request error",
133
+ message: "The request couldn't be completed.",
134
+ isAuth: false,
135
+ };
136
+ }
137
+ return {
138
+ code: null,
139
+ title: "Connection error",
140
+ message: "Couldn't reach the server.",
141
+ isAuth: false,
142
+ };
143
+ }
144
+
145
+ function LockIcon({ className = "" }: { className?: string }): ReactNode {
146
+ return (
147
+ <svg
148
+ viewBox="0 0 24 24"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ strokeWidth={1.5}
152
+ strokeLinecap="round"
153
+ strokeLinejoin="round"
154
+ aria-hidden="true"
155
+ className={className}
156
+ >
157
+ <rect x="3.5" y="10.5" width="17" height="10" rx="2" />
158
+ <path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
159
+ <circle cx="12" cy="15.5" r="1.25" />
160
+ </svg>
161
+ );
162
+ }
163
+
164
+ function AlertIcon({ className = "" }: { className?: string }): ReactNode {
165
+ return (
166
+ <svg
167
+ viewBox="0 0 24 24"
168
+ fill="none"
169
+ stroke="currentColor"
170
+ strokeWidth={1.5}
171
+ strokeLinecap="round"
172
+ strokeLinejoin="round"
173
+ aria-hidden="true"
174
+ className={className}
175
+ >
176
+ <circle cx="12" cy="12" r="9" />
177
+ <path d="M12 8v5" />
178
+ <path d="M12 16.5h.01" />
179
+ </svg>
180
+ );
181
+ }
182
+
183
+ export interface ApiErrorStateProps {
184
+ error: unknown;
185
+ /** Called when the user clicks Retry. Omit to hide the Retry button. */
186
+ onRetry?: () => void;
187
+ /** Render a smaller inline banner (for nested panels) instead of a full card. */
188
+ compact?: boolean;
189
+ className?: string;
190
+ }
191
+
192
+ /**
193
+ * Simple UI for a failed khotan API request — shows an icon, the HTTP status,
194
+ * and a short message, plus an optional Retry button. Intentionally minimal.
195
+ */
196
+ export function ApiErrorState({
197
+ error,
198
+ onRetry,
199
+ compact = false,
200
+ className = "",
201
+ }: ApiErrorStateProps): ReactNode {
202
+ const { code, title, message, isAuth } = describeError(error);
203
+ const Icon = isAuth ? LockIcon : AlertIcon;
204
+
205
+ if (compact) {
206
+ return (
207
+ <div
208
+ role="alert"
209
+ className={`flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 ${className}`}
210
+ >
211
+ <span className="flex items-center gap-2 text-xs font-medium text-foreground">
212
+ <Icon className="h-4 w-4 text-muted-foreground" />
213
+ {code ? `${String(code)} — ${title}` : title}
214
+ </span>
215
+ {onRetry && (
216
+ <button
217
+ type="button"
218
+ onClick={onRetry}
219
+ className="text-xs text-muted-foreground underline-offset-2 hover:underline"
220
+ >
221
+ Retry
222
+ </button>
223
+ )}
224
+ </div>
225
+ );
226
+ }
227
+
228
+ return (
229
+ <div
230
+ role="alert"
231
+ className={`flex flex-col items-center justify-center rounded-lg border border-border bg-card px-6 py-12 text-center ${className}`}
232
+ >
233
+ <Icon className="h-8 w-8 text-muted-foreground" />
234
+ <p className="mt-3 text-sm font-medium text-foreground">
235
+ {code ? `${String(code)} — ${title}` : title}
236
+ </p>
237
+ <p className="mt-1 text-sm text-muted-foreground">{message}</p>
238
+ {onRetry && (
239
+ <button
240
+ type="button"
241
+ onClick={onRetry}
242
+ className="mt-4 text-xs text-muted-foreground underline-offset-2 hover:underline"
243
+ >
244
+ Retry
245
+ </button>
246
+ )}
247
+ </div>
248
+ );
249
+ }
@@ -0,0 +1,11 @@
1
+ import { cache } from "./cache";
2
+
3
+ export const cin7ProductsSnapshotCache = cache({
4
+ name: "cin7-products-snapshot",
5
+ scope: {
6
+ plug: "cin7",
7
+ resource: "products",
8
+ flow: "cin7-to-pollinate-products-relay",
9
+ },
10
+ ttl: "6h",
11
+ });
@@ -0,0 +1,58 @@
1
+ // ============================================================================
2
+ // Cache — durable sync-state storage for flows, relays, and webhooks
3
+ // Generated by khotan CLI · https://github.com/khotan-data
4
+ //
5
+ // This file defines the cache() builder and types. Create named cache
6
+ // definitions for expensive upstream snapshots, checkpoints, and dedupe markers,
7
+ // then register them in your khotan.ts factory config.
8
+ // ============================================================================
9
+
10
+ import type { CacheRegistration, CacheScope } from "khotan-data/factory";
11
+
12
+ export interface CacheConfig {
13
+ /** Unique cache name used by runtime helpers and DB registration */
14
+ name: string;
15
+ /** Optional ownership metadata for humans and runtime validation */
16
+ scope?: CacheScope;
17
+ /** Optional default TTL like "30m", "6h", or 86400 */
18
+ ttl?: string | number;
19
+ }
20
+
21
+ export function cache(config: CacheConfig): CacheRegistration {
22
+ return {
23
+ name: config.name,
24
+ scope: config.scope,
25
+ ttl: config.ttl,
26
+ };
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Usage Example (create a file like caches/cin7-products-snapshot.ts)
31
+ // ---------------------------------------------------------------------------
32
+ //
33
+ // import { cache } from "./cache";
34
+ //
35
+ // export const cin7ProductsSnapshotCache = cache({
36
+ // name: "cin7-products-snapshot",
37
+ // scope: {
38
+ // plug: "cin7",
39
+ // resource: "products",
40
+ // flow: "cin7-to-pollinate-products-relay",
41
+ // },
42
+ // ttl: "6h",
43
+ // });
44
+ //
45
+ // Then register in your khotan config:
46
+ //
47
+ // import { cin7ProductsSnapshotCache } from "./caches/cin7-products-snapshot";
48
+ //
49
+ // const khotanData = khotan({
50
+ // adapter: drizzleAdapter(db),
51
+ // caches: [cin7ProductsSnapshotCache],
52
+ // plugs: [...],
53
+ // });
54
+ //
55
+ // Flow, relay, catch, and pass workflows can then use:
56
+ // await khotanCache(ctx, "cin7-products-snapshot").get("all-products");
57
+ // await khotanCache(ctx, "cin7-products-snapshot").set("all-products", payload);
58
+ // await khotanCache(ctx, "cin7-products-snapshot").delete("all-products");
@@ -24,6 +24,8 @@ export interface CatchContext {
24
24
  headers: Record<string, string>;
25
25
  /** Khotan run ID created for this webhook handler execution */
26
26
  khotanRunId: string;
27
+ /** Internal Khotan instance identifier for helper APIs */
28
+ khotanInstanceId: string;
27
29
  }
28
30
 
29
31
  // ---------------------------------------------------------------------------
@@ -73,19 +75,29 @@ export function catchEvent(config: CatchConfig): CatchRegistration {
73
75
  // Usage Example (create a file like webhooks/pollinate-catch.ts)
74
76
  // ---------------------------------------------------------------------------
75
77
  //
78
+ // import { khotanCache } from "khotan-data/factory";
76
79
  // import { catchEvent, type CatchContext } from "./catch";
77
80
  // Khotan already records webhook deliveries in khotan_webhook_events and links
78
- // them to khotan_runs. Use your catch workflow for app-specific side effects.
81
+ // them to khotan_runs. Use your catch workflow for app-specific side effects
82
+ // and optionally khotanCache(ctx, "name") for dedupe or cursor state.
79
83
  //
80
84
  // async function pollinateCatchWorkflow(ctx: CatchContext) {
81
85
  // "use workflow";
82
86
  //
83
87
  // async function notifyOps() {
84
88
  // "use step";
89
+ // const cache = khotanCache(ctx, "pollinate-webhook-markers");
90
+ // const eventId = String(ctx.event["id"] ?? "");
91
+ // if (eventId && (await cache.get<boolean>(eventId))) return;
92
+ //
85
93
  // console.log("Handled webhook", {
86
94
  // eventType: ctx.eventType,
87
95
  // khotanRunId: ctx.khotanRunId,
88
96
  // });
97
+ //
98
+ // if (eventId) {
99
+ // await cache.set(eventId, true);
100
+ // }
89
101
  // }
90
102
  //
91
103
  // await notifyOps();
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useEffect, useState } from "react";
3
+ import { useCallback, useEffect, useState } from "react";
4
4
  import Link from "next/link";
5
+ import { khotanFetch, ApiErrorState } from "@/components/khotan/api-state";
5
6
 
6
7
  // ============================================================================
7
8
  // Debug Index — Lists all registered plugs for debugging
@@ -23,20 +24,28 @@ export default function DebugIndexPage() {
23
24
  const [plugs, setPlugs] = useState<Plug[]>([]);
24
25
  const [loading, setLoading] = useState(true);
25
26
  const [debugEnabled, setDebugEnabled] = useState<boolean | null>(null);
27
+ const [error, setError] = useState<unknown>(null);
26
28
 
27
- useEffect(() => {
28
- Promise.all([
29
- fetch("/api/khotan/debug").then((r) => r.ok),
30
- fetch("/api/khotan/plugs").then((r) => (r.ok ? r.json() : [])),
31
- ])
32
- .then(([enabled, data]) => {
33
- setDebugEnabled(enabled);
34
- setPlugs(data as Plug[]);
35
- })
36
- .catch(() => setDebugEnabled(false))
37
- .finally(() => setLoading(false));
29
+ const load = useCallback(async () => {
30
+ setLoading(true);
31
+ setError(null);
32
+ try {
33
+ const enabled = await fetch("/api/khotan/debug").then((r) => r.ok);
34
+ setDebugEnabled(enabled);
35
+ if (enabled) {
36
+ setPlugs(await khotanFetch<Plug[]>("/api/khotan/plugs"));
37
+ }
38
+ } catch (err) {
39
+ setError(err);
40
+ } finally {
41
+ setLoading(false);
42
+ }
38
43
  }, []);
39
44
 
45
+ useEffect(() => {
46
+ void load();
47
+ }, [load]);
48
+
40
49
  if (loading) {
41
50
  return (
42
51
  <main className="container mx-auto max-w-3xl px-4 py-10">
@@ -71,31 +80,42 @@ export default function DebugIndexPage() {
71
80
  </a>
72
81
  </div>
73
82
  <h1 className="text-2xl font-bold tracking-tight mb-6">Debug</h1>
74
- <p className="text-muted-foreground mb-6">
75
- Select a plug to test requests through its real code path.
76
- </p>
77
- <div className="grid gap-3">
78
- {plugs.map((plug) => (
79
- <Link
80
- key={plug.id}
81
- href={`/debug/${plug.name}`}
82
- className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:border-foreground/30 hover:bg-muted/50"
83
- >
84
- <div>
85
- <p className="font-medium">{plug.name}</p>
86
- <p className="text-xs text-muted-foreground truncate">
87
- {plug.baseUrl}
83
+ {error ? (
84
+ <ApiErrorState error={error} onRetry={() => void load()} />
85
+ ) : (
86
+ <>
87
+ <p className="text-muted-foreground mb-6">
88
+ Select a plug to test requests through its real code path.
89
+ </p>
90
+ <div className="grid gap-3">
91
+ {plugs.map((plug) => (
92
+ <Link
93
+ key={plug.id}
94
+ href={`/debug/${plug.name}`}
95
+ className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:border-foreground/30 hover:bg-muted/50"
96
+ >
97
+ <div>
98
+ <p className="font-medium">{plug.name}</p>
99
+ <p className="text-xs text-muted-foreground truncate">
100
+ {plug.baseUrl}
101
+ </p>
102
+ </div>
103
+ <div className="flex items-center gap-2">
104
+ <span className="text-xs text-muted-foreground">
105
+ {plug.authType}
106
+ </span>
107
+ <span className="text-muted-foreground">→</span>
108
+ </div>
109
+ </Link>
110
+ ))}
111
+ {plugs.length === 0 && (
112
+ <p className="text-sm text-muted-foreground">
113
+ No plugs registered yet.
88
114
  </p>
89
- </div>
90
- <div className="flex items-center gap-2">
91
- <span className="text-xs text-muted-foreground">
92
- {plug.authType}
93
- </span>
94
- <span className="text-muted-foreground">→</span>
95
- </div>
96
- </Link>
97
- ))}
98
- </div>
115
+ )}
116
+ </div>
117
+ </>
118
+ )}
99
119
  </main>
100
120
  );
101
121
  }
@@ -15,6 +15,7 @@ import { Switch } from "@/components/ui/switch";
15
15
  import { Button } from "@/components/ui/button";
16
16
  import { WirePanel } from "./wire";
17
17
  import { VarPanel } from "./var-panel";
18
+ import { khotanFetch, ApiErrorState } from "./api-state";
18
19
 
19
20
  // ============================================================================
20
21
  // Khotan Hub — Dashboard for configured plugs and flows
@@ -108,7 +109,7 @@ export function KhotanHub({
108
109
  const [flows, setFlows] = useState<Flow[]>([]);
109
110
  const [webhookHandlers, setWebhookHandlers] = useState<WebhookHandler[]>([]);
110
111
  const [loading, setLoading] = useState(true);
111
- const [error, setError] = useState<string | null>(null);
112
+ const [error, setError] = useState<unknown>(null);
112
113
  const [selectedPlugId, setSelectedPlugId] = useState<string | null>(null);
113
114
  const [debugEnabled, setDebugEnabled] = useState(false);
114
115
 
@@ -122,17 +123,14 @@ export function KhotanHub({
122
123
  setLoading(true);
123
124
  setError(null);
124
125
  try {
125
- const [plugsRes, flowsRes] = await Promise.all([
126
- fetch("/api/khotan/plugs"),
127
- fetch("/api/khotan/flows"),
126
+ const [plugsData, flowsData] = await Promise.all([
127
+ khotanFetch<Plug[]>("/api/khotan/plugs"),
128
+ khotanFetch<Flow[]>("/api/khotan/flows"),
128
129
  ]);
129
- if (!plugsRes.ok || !flowsRes.ok) {
130
- throw new Error("Failed to fetch khotan data");
131
- }
132
- setPlugs(await plugsRes.json());
133
- setFlows(await flowsRes.json());
130
+ setPlugs(plugsData);
131
+ setFlows(flowsData);
134
132
  } catch (err) {
135
- setError(err instanceof Error ? err.message : "Unknown error");
133
+ setError(err);
136
134
  } finally {
137
135
  setLoading(false);
138
136
  }
@@ -201,19 +199,7 @@ export function KhotanHub({
201
199
  }
202
200
 
203
201
  if (error) {
204
- return (
205
- <Card>
206
- <CardContent className="py-8 text-center">
207
- <p className="text-destructive mb-4">{error}</p>
208
- <button
209
- onClick={fetchData}
210
- className="text-sm underline hover:no-underline"
211
- >
212
- Retry
213
- </button>
214
- </CardContent>
215
- </Card>
216
- );
202
+ return <ApiErrorState error={error} onRetry={fetchData} />;
217
203
  }
218
204
 
219
205
  if (plugs.length === 0) {
@@ -5,7 +5,8 @@
5
5
  // This file defines the inflow() builder and types. Create per-service flow
6
6
  // files (e.g. shopify-products.ts) using this builder to pull records from an
7
7
  // external service, transform them, and load them into your app with durable,
8
- // retryable Vercel Workflow steps.
8
+ // retryable Vercel Workflow steps. Inflow workflows can also use
9
+ // khotanCache(ctx, "name") for checkpoints and last-seen markers across runs.
9
10
  // ============================================================================
10
11
 
11
12
  import type {
@@ -47,11 +48,10 @@ export function inflow(config: InflowConfig): FlowRegistration {
47
48
  // Usage Example (create a file like flows/shopify-products.ts)
48
49
  // ---------------------------------------------------------------------------
49
50
  //
50
- // import { inflow, type InflowContext } from "./inflow";
51
+ // import { bindWorkflowPlug, inflow, type InflowContext, sendUpdate } from "khotan-data/factory";
51
52
  // import { db } from "@/db";
52
53
  // import { products } from "@/db/schema";
53
54
  // import { shopifyPlug } from "../plugs/shopify";
54
- // import { sendUpdate } from "khotan-data/factory";
55
55
  //
56
56
  // async function shopifyProductsWorkflow(ctx: InflowContext) {
57
57
  // "use workflow";
@@ -64,10 +64,9 @@ export function inflow(config: InflowConfig): FlowRegistration {
64
64
  // runType: ctx.runType,
65
65
  // });
66
66
  // await sendUpdate({ message: "Starting Shopify products inflow" });
67
+ // const shopify = bindWorkflowPlug(shopifyPlug, ctx);
67
68
  //
68
- // const response = await shopifyPlug.get<{ data?: Array<{ id: string; sku?: string }> }>("/products", {
69
- // vars: ctx.vars,
70
- // });
69
+ // const response = await shopify.get<{ data?: Array<{ id: string; sku?: string }> }>("/products");
71
70
  // const records = Array.isArray(response.data) ? response.data : [];
72
71
  // await sendUpdate({ message: `Fetched ${records.length} products`, extracted: records.length });
73
72
  //
@@ -1,5 +1,5 @@
1
1
  // ============================================================================
2
- // Khotan Config — register your plugs and flows here
2
+ // Khotan Config — register your plugs, caches, and flows here
3
3
  // Generated by khotan CLI · https://github.com/khotan-data
4
4
  //
5
5
  // Import your Drizzle database instance and register plugs below.
@@ -13,13 +13,30 @@ import { db } from "@/db";
13
13
  const khotanData = khotan({
14
14
  adapter: drizzleAdapter(db),
15
15
 
16
+ // ── Security ──────────────────────────────────────────────────────────────
17
+ // The management API (/api/khotan/*) and the Hub dashboard expose plug
18
+ // credentials and operational controls. Gate every management route behind
19
+ // your auth layer with `authorize`. It receives the raw Request, so it works
20
+ // directly with session libraries like better-auth:
21
+ //
22
+ // import { auth } from "@/lib/auth";
23
+ //
24
+ // authorize: async (request) => {
25
+ // const session = await auth.api.getSession({ headers: request.headers });
26
+ // return Boolean(session?.user); // or: session?.user?.role === "admin"
27
+ // },
28
+ //
29
+ // Inbound webhooks, the cron dispatcher (CRON_SECRET), and debug routes are
30
+ // exempt automatically. Without `authorize`, the API is PUBLIC.
31
+ // authorize: async (request) => { /* return true to allow */ return false; },
32
+
16
33
  // Resources define logical entity types for cross-referencing across plugs.
17
- // The connectField names the field used to match records (e.g. "sku").
34
+ // The mapping block declares the shared identity contract for that resource.
18
35
  resources: [
19
36
  // Example resource registration:
20
37
  //
21
- // { name: "products", connectField: "sku", description: "Product catalog" },
22
- // { name: "orders", connectField: "order_number" },
38
+ // { name: "products", mapping: { connectField: "sku" }, description: "Product catalog" },
39
+ // { name: "orders", mapping: { connectField: "order_number" } },
23
40
  ],
24
41
 
25
42
  plugs: [
@@ -35,6 +52,15 @@ const khotanData = khotan({
35
52
  // ],
36
53
  // },
37
54
  ],
55
+
56
+ // Caches store durable key/value state for relays, inflows, outflows, and
57
+ // webhook workflows. Register named caches you want to access via
58
+ // khotanCache(ctx, "cache-name") or khotanData.cache("cache-name").
59
+ caches: [
60
+ // Example cache registration:
61
+ //
62
+ // cin7ProductsSnapshotCache,
63
+ ],
38
64
  });
39
65
 
40
66
  export default khotanData;