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.
- package/README.md +60 -6
- package/dist/cli.js +292 -8
- package/dist/factory.cjs +1083 -99
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +225 -38
- package/dist/factory.d.ts +225 -38
- package/dist/factory.js +1082 -101
- package/dist/factory.js.map +1 -1
- package/dist/templates/cache.example.ts +11 -0
- package/dist/templates/cache.ts +58 -0
- package/dist/templates/catch.ts +13 -1
- package/dist/templates/inflow.ts +5 -6
- package/dist/templates/khotan-config.ts +13 -4
- package/dist/templates/mapping-browser.tsx +761 -0
- package/dist/templates/mappings-page.tsx +9 -0
- package/dist/templates/outflow.ts +5 -5
- package/dist/templates/pass.ts +10 -0
- package/dist/templates/relay.example.ts +11 -1
- package/dist/templates/relay.ts +16 -7
- package/dist/templates/schema.ts +81 -0
- package/dist/templates/skill-plug.md +38 -15
- package/dist/templates/skill-setup.md +44 -2
- package/package.json +1 -1
|
@@ -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 "
|
|
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
|
|
69
|
-
// vars: ctx.vars,
|
|
70
|
-
// body: record,
|
|
71
|
-
// });
|
|
71
|
+
// await hubspot.post("/products", { body: record });
|
|
72
72
|
// }
|
|
73
73
|
//
|
|
74
74
|
// return {
|
package/dist/templates/pass.ts
CHANGED
|
@@ -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: {
|
|
54
|
+
metadata: {
|
|
55
|
+
relay: ctx.flow.name,
|
|
56
|
+
to: ctx.flow.to,
|
|
57
|
+
previousCount: previousRecords.length,
|
|
58
|
+
},
|
|
49
59
|
};
|
|
50
60
|
}
|
|
51
61
|
|
package/dist/templates/relay.ts
CHANGED
|
@@ -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 "
|
|
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
|
|
70
|
-
//
|
|
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
|
|
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: {
|
|
87
|
+
// metadata: {
|
|
88
|
+
// relay: ctx.flow.name,
|
|
89
|
+
// to: ctx.flow.to,
|
|
90
|
+
// previousCount: previous?.length ?? 0,
|
|
91
|
+
// },
|
|
83
92
|
// };
|
|
84
93
|
// }
|
|
85
94
|
//
|
package/dist/templates/schema.ts
CHANGED
|
@@ -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
|
-
##
|
|
101
|
+
## Preferred Pattern
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
Keep each integration in a single app-owned plug file when possible:
|
|
104
104
|
|
|
105
105
|
```typescript
|
|
106
|
-
import {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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`
|