khotan-data 0.1.0 → 0.1.1

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,9 @@
1
+ import { KhotanMappingBrowser } from "@/components/khotan/mapping-browser";
2
+
3
+ export default function KhotanMappingsPage() {
4
+ return (
5
+ <main className="container mx-auto max-w-7xl px-4 py-10">
6
+ <KhotanMappingBrowser />
7
+ </main>
8
+ );
9
+ }
@@ -5,6 +5,8 @@
5
5
  // This file defines the outflow() builder and types. Create per-service flow
6
6
  // files (e.g. crm-audiences.ts) using this builder to read app data and push it
7
7
  // to an external service with durable, retryable Vercel Workflow steps.
8
+ // Outflow workflows can also use khotanCache(ctx, "name") for checkpoints, cursor
9
+ // state, or dedupe markers between runs.
8
10
  // ============================================================================
9
11
 
10
12
  import type {
@@ -46,7 +48,7 @@ export function outflow(config: OutflowConfig): FlowRegistration {
46
48
  // Usage Example (create a file like flows/hubspot-products.ts)
47
49
  // ---------------------------------------------------------------------------
48
50
  //
49
- // import { outflow, type OutflowContext } from "./outflow";
51
+ // import { bindWorkflowPlug, outflow, type OutflowContext } from "khotan-data/factory";
50
52
  // import { db } from "@/db";
51
53
  // import { products } from "@/db/schema";
52
54
  // import { hubspotPlug } from "../plugs/hubspot";
@@ -61,14 +63,12 @@ export function outflow(config: OutflowConfig): FlowRegistration {
61
63
  // khotanRunId: ctx.khotanRunId,
62
64
  // runType: ctx.runType,
63
65
  // });
66
+ // const hubspot = bindWorkflowPlug(hubspotPlug, ctx);
64
67
  //
65
68
  // const records = await db.select().from(products);
66
69
  //
67
70
  // for (const record of records) {
68
- // await hubspotPlug.post("/products", {
69
- // vars: ctx.vars,
70
- // body: record,
71
- // });
71
+ // await hubspot.post("/products", { body: record });
72
72
  // }
73
73
  //
74
74
  // return {
@@ -26,6 +26,8 @@ export interface PassContext {
26
26
  destVars: Record<string, string>;
27
27
  /** Khotan run ID created for this webhook handler execution */
28
28
  khotanRunId: string;
29
+ /** Internal Khotan instance identifier for helper APIs */
30
+ khotanInstanceId: string;
29
31
  }
30
32
 
31
33
  // ---------------------------------------------------------------------------
@@ -79,6 +81,7 @@ export function pass(config: PassConfig): PassRegistration {
79
81
  // Usage Example (create a file like webhooks/pollinate-to-slack.ts)
80
82
  // ---------------------------------------------------------------------------
81
83
  //
84
+ // import { khotanCache } from "khotan-data/factory";
82
85
  // import { pass, type PassContext } from "./pass";
83
86
  // import { plug } from "../plugs/plug";
84
87
  //
@@ -87,6 +90,9 @@ export function pass(config: PassConfig): PassRegistration {
87
90
  //
88
91
  // async function forwardEvent() {
89
92
  // "use step";
93
+ // const cache = khotanCache(ctx, "pollinate-forwarded-events");
94
+ // const eventId = String(ctx.event["id"] ?? "");
95
+ // if (eventId && (await cache.get<boolean>(eventId))) return;
90
96
  //
91
97
  // // Construct destination plug from destVars
92
98
  // const slackPlug = plug({
@@ -102,6 +108,10 @@ export function pass(config: PassConfig): PassRegistration {
102
108
  // event: ctx.event,
103
109
  // },
104
110
  // });
111
+ //
112
+ // if (eventId) {
113
+ // await cache.set(eventId, true);
114
+ // }
105
115
  // }
106
116
  //
107
117
  // await forwardEvent();
@@ -6,6 +6,7 @@
6
6
  // exported flow in {outputDir}/khotan.ts.
7
7
  // ============================================================================
8
8
 
9
+ import { khotanCache } from "khotan-data/factory";
9
10
  import { relay, type RelayContext } from "./relay";
10
11
 
11
12
  async function shopifyToHubspotWorkflow(ctx: RelayContext) {
@@ -29,6 +30,11 @@ async function shopifyToHubspotWorkflow(ctx: RelayContext) {
29
30
  data?: Array<Record<string, unknown>>;
30
31
  };
31
32
  const records = Array.isArray(payload.data) ? payload.data : [];
33
+ const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
34
+ const previousRecords =
35
+ (await snapshotCache.get<Array<Record<string, unknown>>>("latest")) ?? [];
36
+
37
+ await snapshotCache.set("latest", records, { ttl: "6h" });
32
38
 
33
39
  for (const record of records) {
34
40
  await fetch("https://destination.example.com/products", {
@@ -45,7 +51,11 @@ async function shopifyToHubspotWorkflow(ctx: RelayContext) {
45
51
  extracted: records.length,
46
52
  transformed: records.length,
47
53
  created: records.length,
48
- metadata: { relay: ctx.flow.name, to: ctx.flow.to },
54
+ metadata: {
55
+ relay: ctx.flow.name,
56
+ to: ctx.flow.to,
57
+ previousCount: previousRecords.length,
58
+ },
49
59
  };
50
60
  }
51
61
 
@@ -5,7 +5,8 @@
5
5
  // This file defines the relay() builder and types. Create per-service flow
6
6
  // files (e.g. shopify-to-hubspot.ts) using this builder to read from the source
7
7
  // plug and forward to a destination system with durable, retryable Vercel
8
- // Workflow steps.
8
+ // Workflow steps. Relay workflows can also use khotanCache(ctx, "name") for durable
9
+ // snapshots, checkpoints, and dedupe state between runs.
9
10
  // ============================================================================
10
11
 
11
12
  import type {
@@ -50,7 +51,7 @@ export function relay(config: RelayConfig): FlowRegistration {
50
51
  // Usage Example (create a file like flows/shopify-to-hubspot.ts)
51
52
  // ---------------------------------------------------------------------------
52
53
  //
53
- // import { relay, type RelayContext } from "./relay";
54
+ // import { bindWorkflowPlug, khotanCache, relay, type RelayContext } from "khotan-data/factory";
54
55
  // import { shopifyPlug } from "../plugs/shopify";
55
56
  // import { hubspotPlug } from "../plugs/hubspot";
56
57
  //
@@ -65,21 +66,29 @@ export function relay(config: RelayConfig): FlowRegistration {
65
66
  // khotanRunId: ctx.khotanRunId,
66
67
  // runType: ctx.runType,
67
68
  // });
69
+ // const shopify = bindWorkflowPlug(shopifyPlug, ctx);
70
+ // const hubspot = bindWorkflowPlug(hubspotPlug, ctx, "hubspot");
68
71
  //
69
- // const response = await shopifyPlug.get<{ data?: Array<Record<string, unknown>> }>("/products", {
70
- // vars: ctx.vars,
71
- // });
72
+ // const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
73
+ // const previous = await snapshotCache.get<Array<Record<string, unknown>>>("latest");
74
+ //
75
+ // const response = await shopify.get<{ data?: Array<Record<string, unknown>> }>("/products");
72
76
  // const records = Array.isArray(response.data) ? response.data : [];
77
+ // await snapshotCache.set("latest", records);
73
78
  //
74
79
  // for (const record of records) {
75
- // await hubspotPlug.post("/products", { body: record });
80
+ // await hubspot.post("/products", { body: record });
76
81
  // }
77
82
  //
78
83
  // return {
79
84
  // extracted: records.length,
80
85
  // transformed: records.length,
81
86
  // created: records.length,
82
- // metadata: { relay: ctx.flow.name, to: ctx.flow.to },
87
+ // metadata: {
88
+ // relay: ctx.flow.name,
89
+ // to: ctx.flow.to,
90
+ // previousCount: previous?.length ?? 0,
91
+ // },
83
92
  // };
84
93
  // }
85
94
  //
@@ -303,6 +303,67 @@ export const khotanMappings = pgTable(
303
303
  ],
304
304
  );
305
305
 
306
+ // ---------------------------------------------------------------------------
307
+ // khotan_caches — one row per registered durable cache namespace
308
+ // ---------------------------------------------------------------------------
309
+
310
+ export const khotanCaches = pgTable(
311
+ "khotan_caches",
312
+ {
313
+ id: text("id")
314
+ .primaryKey()
315
+ .$defaultFn(() => crypto.randomUUID()),
316
+ name: text("name").notNull().unique(),
317
+ scope: jsonb("scope").$type<{
318
+ plug?: string;
319
+ resource?: string;
320
+ flow?: string;
321
+ }>(),
322
+ ttlSeconds: integer("ttl_seconds"),
323
+ createdAt: timestamp("created_at", { withTimezone: true })
324
+ .defaultNow()
325
+ .notNull(),
326
+ updatedAt: timestamp("updated_at", { withTimezone: true })
327
+ .defaultNow()
328
+ .notNull(),
329
+ },
330
+ (table) => [index("khotan_caches_name_idx").on(table.name)],
331
+ );
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // khotan_cache_entries — latest-value durable cache rows keyed by cache + key
335
+ // ---------------------------------------------------------------------------
336
+
337
+ export const khotanCacheEntries = pgTable(
338
+ "khotan_cache_entries",
339
+ {
340
+ id: text("id")
341
+ .primaryKey()
342
+ .$defaultFn(() => crypto.randomUUID()),
343
+ cacheId: text("cache_id")
344
+ .notNull()
345
+ .references(() => khotanCaches.id),
346
+ key: text("key").notNull(),
347
+ value: jsonb("value").notNull(),
348
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
349
+ createdAt: timestamp("created_at", { withTimezone: true })
350
+ .defaultNow()
351
+ .notNull(),
352
+ updatedAt: timestamp("updated_at", { withTimezone: true })
353
+ .defaultNow()
354
+ .notNull(),
355
+ },
356
+ (table) => [
357
+ unique("khotan_cache_entries_cache_id_key_unique").on(
358
+ table.cacheId,
359
+ table.key,
360
+ ),
361
+ index("khotan_cache_entries_cache_id_idx").on(table.cacheId),
362
+ index("khotan_cache_entries_cache_id_key_idx").on(table.cacheId, table.key),
363
+ index("khotan_cache_entries_expires_at_idx").on(table.expiresAt),
364
+ ],
365
+ );
366
+
306
367
  // ---------------------------------------------------------------------------
307
368
  // Relations
308
369
  // ---------------------------------------------------------------------------
@@ -395,6 +456,20 @@ export const khotanMappingsRelations = relations(khotanMappings, ({ one }) => ({
395
456
  }),
396
457
  }));
397
458
 
459
+ export const khotanCachesRelations = relations(khotanCaches, ({ many }) => ({
460
+ entries: many(khotanCacheEntries),
461
+ }));
462
+
463
+ export const khotanCacheEntriesRelations = relations(
464
+ khotanCacheEntries,
465
+ ({ one }) => ({
466
+ cache: one(khotanCaches, {
467
+ fields: [khotanCacheEntries.cacheId],
468
+ references: [khotanCaches.id],
469
+ }),
470
+ }),
471
+ );
472
+
398
473
  // ---------------------------------------------------------------------------
399
474
  // Type helpers
400
475
  // ---------------------------------------------------------------------------
@@ -422,3 +497,9 @@ export type NewKhotanResource = typeof khotanResources.$inferInsert;
422
497
 
423
498
  export type KhotanMapping = typeof khotanMappings.$inferSelect;
424
499
  export type NewKhotanMapping = typeof khotanMappings.$inferInsert;
500
+
501
+ export type KhotanCache = typeof khotanCaches.$inferSelect;
502
+ export type NewKhotanCache = typeof khotanCaches.$inferInsert;
503
+
504
+ export type KhotanCacheEntry = typeof khotanCacheEntries.$inferSelect;
505
+ export type NewKhotanCacheEntry = typeof khotanCacheEntries.$inferInsert;
@@ -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
  {
@@ -92,6 +92,7 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
92
92
  | `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
93
93
  | `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
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 |
95
96
 
96
97
  ## Next.js Config
97
98
 
@@ -111,6 +112,47 @@ curl http://localhost:3000/api/khotan/flows # Should list flows
111
112
  curl http://localhost:3000/api/khotan/resources # Should list resources
112
113
  ```
113
114
 
115
+ ## Scheduled Flows On Vercel
116
+
117
+ 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.
118
+
119
+ Add one entry to `vercel.json`:
120
+
121
+ ```json
122
+ {
123
+ "crons": [
124
+ { "path": "/api/khotan/cron", "schedule": "* * * * *" }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ Then define schedules only on your flows in `{outputDir}/khotan.ts`:
130
+
131
+ ```typescript
132
+ {
133
+ name: "products-inflow",
134
+ type: "inflow",
135
+ schedule: "0 * * * *",
136
+ resource: "products",
137
+ }
138
+ ```
139
+
140
+ 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>`.
141
+
142
+ ## Typical Build Order
143
+
144
+ After init and schema setup, the usual path to a working sync is:
145
+
146
+ 1. Add or author a plug file for the external service.
147
+ 2. Define a few typed endpoints directly on the plug with Zod response schemas.
148
+ 3. Start the app with `KHOTAN_DEBUG=1`.
149
+ 4. Verify the plug is visible with `npx khotan plug --list` and `npx khotan plug myPlug --info`.
150
+ 5. Hit live endpoints with `npx khotan plug myPlug --endpoint listProducts --compare` until the schemas match the real API shape you intend to use.
151
+ 6. Register the plug in `{outputDir}/khotan.ts` with resources and flows.
152
+ 7. Only after endpoint verification, build inflows, relays, outflows, or webhook handlers on top of those live-checked endpoints.
153
+
154
+ This keeps sync logic grounded in real API payloads before you write pagination, mapping, or transformation code.
155
+
114
156
  ## Troubleshooting
115
157
 
116
158
  - **Empty plug list**: Factory upserts on first request — hit any endpoint first, then check `/plugs`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "khotan-data",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Data primitives for TypeScript — ETL pipelines, transforms, and Drizzle Postgres integration.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",