khotan-data 0.2.0 → 0.3.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 +34 -22
- package/dist/cli.js +88 -16
- package/dist/factory.cjs +8 -1
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +9 -1
- package/dist/factory.d.ts +9 -1
- package/dist/factory.js +8 -1
- package/dist/factory.js.map +1 -1
- package/dist/templates/catch.example.ts +25 -17
- package/dist/templates/catch.ts +20 -15
- package/dist/templates/hub.tsx +96 -13
- package/dist/templates/inflow.example.ts +46 -38
- package/dist/templates/inflow.ts +37 -31
- package/dist/templates/khotan-config.ts +16 -6
- package/dist/templates/outflow.example.ts +39 -31
- package/dist/templates/outflow.ts +28 -23
- package/dist/templates/pass.example.ts +38 -30
- package/dist/templates/pass.ts +29 -24
- package/dist/templates/relay.example.ts +52 -44
- package/dist/templates/relay.ts +38 -33
- package/dist/templates/skill-dashboard.md +2 -1
- package/dist/templates/skill-setup.md +77 -1
- package/dist/templates/skill-webhook.md +45 -23
- package/package.json +4 -4
|
@@ -4,43 +4,51 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Copy this file, rename it for your source/destination pair, and register the
|
|
6
6
|
// exported pass handler in {outputDir}/khotan.ts.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT — Workflow step structure:
|
|
9
|
+
// Declare "use step" functions at module top level and pass them only
|
|
10
|
+
// serializable values (the `ctx` object is plain data and is safe to pass).
|
|
11
|
+
// Do NOT nest step functions inside the "use workflow" function — the Workflow
|
|
12
|
+
// compiler cannot hoist closures that capture workflow scope, and they fail at
|
|
13
|
+
// runtime in the sandbox. Keep the workflow body limited to orchestration.
|
|
7
14
|
// ============================================================================
|
|
8
15
|
|
|
9
16
|
import { pass, type PassContext } from "./pass";
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
khotanRunId: ctx.khotanRunId,
|
|
19
|
-
});
|
|
18
|
+
// Step: full Node.js access, retried independently. Receives serializable ctx.
|
|
19
|
+
async function forwardEvent(ctx: PassContext) {
|
|
20
|
+
"use step";
|
|
21
|
+
console.log("Forwarding webhook event", {
|
|
22
|
+
eventType: ctx.eventType,
|
|
23
|
+
khotanRunId: ctx.khotanRunId,
|
|
24
|
+
});
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
26
|
+
await fetch("https://slack.com/api/chat.postMessage", {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
Authorization: `Bearer ${ctx.destVars["botToken"] ?? ""}`,
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify({
|
|
33
|
+
channel: ctx.destVars["channelId"],
|
|
34
|
+
text: `Received ${ctx.eventType}`,
|
|
35
|
+
blocks: [
|
|
36
|
+
{
|
|
37
|
+
type: "section",
|
|
38
|
+
text: {
|
|
39
|
+
type: "mrkdwn",
|
|
40
|
+
text: `Received ${ctx.eventType} from Stripe`,
|
|
37
41
|
},
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
// Workflow: orchestration only. Calls top-level steps with serializable args.
|
|
49
|
+
async function stripeToSlackWorkflow(ctx: PassContext) {
|
|
50
|
+
"use workflow";
|
|
51
|
+
await forwardEvent(ctx);
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export const stripeToSlack = pass({
|
package/dist/templates/pass.ts
CHANGED
|
@@ -85,36 +85,41 @@ export function pass(config: PassConfig): PassRegistration {
|
|
|
85
85
|
// import { pass, type PassContext } from "./pass";
|
|
86
86
|
// import { plug } from "../plugs/plug";
|
|
87
87
|
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
88
|
+
// Declare "use step" functions at MODULE TOP LEVEL and pass them serializable
|
|
89
|
+
// values only (`ctx` is plain data). Do NOT nest steps inside the "use workflow"
|
|
90
|
+
// function — closures over workflow scope cannot be hoisted and fail at runtime.
|
|
90
91
|
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
92
|
+
// // Step: top-level, full Node.js access, retried independently.
|
|
93
|
+
// async function forwardEvent(ctx: PassContext) {
|
|
94
|
+
// "use step";
|
|
95
|
+
// const cache = khotanCache(ctx, "pollinate-forwarded-events");
|
|
96
|
+
// const eventId = String(ctx.event["id"] ?? "");
|
|
97
|
+
// if (eventId && (await cache.get<boolean>(eventId))) return;
|
|
96
98
|
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
99
|
+
// // Construct destination plug from destVars
|
|
100
|
+
// const slackPlug = plug({
|
|
101
|
+
// name: "slack",
|
|
102
|
+
// baseUrl: "https://hooks.slack.com",
|
|
103
|
+
// authType: "bearer",
|
|
104
|
+
// auth: { bearer: { token: ctx.destVars["token"] ?? "" } },
|
|
105
|
+
// });
|
|
104
106
|
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// await slackPlug.post("/services/webhook", {
|
|
108
|
+
// body: {
|
|
109
|
+
// text: `Received ${ctx.eventType} event from pollinate`,
|
|
110
|
+
// event: ctx.event,
|
|
111
|
+
// },
|
|
112
|
+
// });
|
|
111
113
|
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// }
|
|
114
|
+
// if (eventId) {
|
|
115
|
+
// await cache.set(eventId, true);
|
|
115
116
|
// }
|
|
117
|
+
// }
|
|
116
118
|
//
|
|
117
|
-
//
|
|
119
|
+
// // Workflow: orchestration only.
|
|
120
|
+
// async function pollinateToSlackWorkflow(ctx: PassContext) {
|
|
121
|
+
// "use workflow";
|
|
122
|
+
// await forwardEvent(ctx);
|
|
118
123
|
// }
|
|
119
124
|
//
|
|
120
125
|
// export const pollinateToSlack = pass({
|
|
@@ -4,62 +4,70 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Copy this file, rename it for your source/destination pair, and register the
|
|
6
6
|
// exported flow in {outputDir}/khotan.ts.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT — Workflow step structure:
|
|
9
|
+
// Declare "use step" functions at module top level and pass them only
|
|
10
|
+
// serializable values (the `ctx` object is plain data and is safe to pass).
|
|
11
|
+
// Do NOT nest step functions inside the "use workflow" function — the Workflow
|
|
12
|
+
// compiler cannot hoist closures that capture workflow scope, and they fail at
|
|
13
|
+
// runtime in the sandbox. Keep the workflow body limited to orchestration.
|
|
7
14
|
// ============================================================================
|
|
8
15
|
|
|
9
16
|
import { khotanCache } from "khotan-data/factory";
|
|
10
17
|
import { relay, type RelayContext } from "./relay";
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
// Step: full Node.js access, retried independently. Receives serializable ctx.
|
|
20
|
+
async function forwardProducts(ctx: RelayContext) {
|
|
21
|
+
"use step";
|
|
22
|
+
console.log("Starting relay", {
|
|
23
|
+
flow: ctx.flow.name,
|
|
24
|
+
to: ctx.flow.to,
|
|
25
|
+
khotanRunId: ctx.khotanRunId,
|
|
26
|
+
runType: ctx.runType,
|
|
27
|
+
});
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
const sourceResponse = await fetch("https://source.example.com/products", {
|
|
30
|
+
headers: {
|
|
31
|
+
Authorization: `Bearer ${ctx.vars["sourceToken"] ?? ""}`,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const payload = (await sourceResponse.json()) as {
|
|
35
|
+
data?: Array<Record<string, unknown>>;
|
|
36
|
+
};
|
|
37
|
+
const records = Array.isArray(payload.data) ? payload.data : [];
|
|
38
|
+
const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
|
|
39
|
+
const previousRecords =
|
|
40
|
+
(await snapshotCache.get<Array<Record<string, unknown>>>("latest")) ?? [];
|
|
41
|
+
|
|
42
|
+
await snapshotCache.set("latest", records, { ttl: "6h" });
|
|
23
43
|
|
|
24
|
-
|
|
44
|
+
for (const record of records) {
|
|
45
|
+
await fetch("https://destination.example.com/products", {
|
|
46
|
+
method: "POST",
|
|
25
47
|
headers: {
|
|
26
|
-
Authorization: `Bearer ${ctx.vars["
|
|
48
|
+
Authorization: `Bearer ${ctx.vars["destinationToken"] ?? ""}`,
|
|
49
|
+
"Content-Type": "application/json",
|
|
27
50
|
},
|
|
51
|
+
body: JSON.stringify(record),
|
|
28
52
|
});
|
|
29
|
-
const payload = (await sourceResponse.json()) as {
|
|
30
|
-
data?: Array<Record<string, unknown>>;
|
|
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" });
|
|
38
|
-
|
|
39
|
-
for (const record of records) {
|
|
40
|
-
await fetch("https://destination.example.com/products", {
|
|
41
|
-
method: "POST",
|
|
42
|
-
headers: {
|
|
43
|
-
Authorization: `Bearer ${ctx.vars["destinationToken"] ?? ""}`,
|
|
44
|
-
"Content-Type": "application/json",
|
|
45
|
-
},
|
|
46
|
-
body: JSON.stringify(record),
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
extracted: records.length,
|
|
52
|
-
transformed: records.length,
|
|
53
|
-
created: records.length,
|
|
54
|
-
metadata: {
|
|
55
|
-
relay: ctx.flow.name,
|
|
56
|
-
to: ctx.flow.to,
|
|
57
|
-
previousCount: previousRecords.length,
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
53
|
}
|
|
61
54
|
|
|
62
|
-
return
|
|
55
|
+
return {
|
|
56
|
+
extracted: records.length,
|
|
57
|
+
transformed: records.length,
|
|
58
|
+
created: records.length,
|
|
59
|
+
metadata: {
|
|
60
|
+
relay: ctx.flow.name,
|
|
61
|
+
to: ctx.flow.to,
|
|
62
|
+
previousCount: previousRecords.length,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Workflow: orchestration only. Calls top-level steps with serializable args.
|
|
68
|
+
async function shopifyToHubspotWorkflow(ctx: RelayContext) {
|
|
69
|
+
"use workflow";
|
|
70
|
+
return forwardProducts(ctx);
|
|
63
71
|
}
|
|
64
72
|
|
|
65
73
|
export const shopifyToHubspotRelay = relay({
|
package/dist/templates/relay.ts
CHANGED
|
@@ -51,48 +51,53 @@ export function relay(config: RelayConfig): FlowRegistration {
|
|
|
51
51
|
// Usage Example (create a file like flows/shopify-to-hubspot.ts)
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
53
|
//
|
|
54
|
+
// Declare "use step" functions at MODULE TOP LEVEL and pass them serializable
|
|
55
|
+
// values only (`ctx` is plain data). Do NOT nest steps inside the "use workflow"
|
|
56
|
+
// function — closures over workflow scope cannot be hoisted and fail at runtime.
|
|
57
|
+
//
|
|
54
58
|
// import { bindWorkflowPlug, khotanCache, relay, type RelayContext } from "khotan-data/factory";
|
|
55
59
|
// import { shopifyPlug } from "../plugs/shopify";
|
|
56
60
|
// import { hubspotPlug } from "../plugs/hubspot";
|
|
57
61
|
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// const shopify = bindWorkflowPlug(shopifyPlug, ctx);
|
|
70
|
-
// const hubspot = bindWorkflowPlug(hubspotPlug, ctx, "hubspot");
|
|
71
|
-
//
|
|
72
|
-
// const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
|
|
73
|
-
// const previous = await snapshotCache.get<Array<Record<string, unknown>>>("latest");
|
|
62
|
+
// // Step: top-level, full Node.js access, retried independently.
|
|
63
|
+
// async function forwardProducts(ctx: RelayContext) {
|
|
64
|
+
// "use step";
|
|
65
|
+
// console.log("Starting relay", {
|
|
66
|
+
// flow: ctx.flow.name,
|
|
67
|
+
// to: ctx.flow.to,
|
|
68
|
+
// khotanRunId: ctx.khotanRunId,
|
|
69
|
+
// runType: ctx.runType,
|
|
70
|
+
// });
|
|
71
|
+
// const shopify = bindWorkflowPlug(shopifyPlug, ctx);
|
|
72
|
+
// const hubspot = bindWorkflowPlug(hubspotPlug, ctx, "hubspot");
|
|
74
73
|
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// await snapshotCache.set("latest", records);
|
|
74
|
+
// const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
|
|
75
|
+
// const previous = await snapshotCache.get<Array<Record<string, unknown>>>("latest");
|
|
78
76
|
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
77
|
+
// const response = await shopify.get<{ data?: Array<Record<string, unknown>> }>("/products");
|
|
78
|
+
// const records = Array.isArray(response.data) ? response.data : [];
|
|
79
|
+
// await snapshotCache.set("latest", records);
|
|
82
80
|
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// transformed: records.length,
|
|
86
|
-
// created: records.length,
|
|
87
|
-
// metadata: {
|
|
88
|
-
// relay: ctx.flow.name,
|
|
89
|
-
// to: ctx.flow.to,
|
|
90
|
-
// previousCount: previous?.length ?? 0,
|
|
91
|
-
// },
|
|
92
|
-
// };
|
|
81
|
+
// for (const record of records) {
|
|
82
|
+
// await hubspot.post("/products", { body: record });
|
|
93
83
|
// }
|
|
94
84
|
//
|
|
95
|
-
// return
|
|
85
|
+
// return {
|
|
86
|
+
// extracted: records.length,
|
|
87
|
+
// transformed: records.length,
|
|
88
|
+
// created: records.length,
|
|
89
|
+
// metadata: {
|
|
90
|
+
// relay: ctx.flow.name,
|
|
91
|
+
// to: ctx.flow.to,
|
|
92
|
+
// previousCount: previous?.length ?? 0,
|
|
93
|
+
// },
|
|
94
|
+
// };
|
|
95
|
+
// }
|
|
96
|
+
//
|
|
97
|
+
// // Workflow: orchestration only.
|
|
98
|
+
// async function shopifyToHubspotWorkflow(ctx: RelayContext) {
|
|
99
|
+
// "use workflow";
|
|
100
|
+
// return forwardProducts(ctx);
|
|
96
101
|
// }
|
|
97
102
|
//
|
|
98
103
|
// export const shopifyToHubspotRelay = relay({
|
|
@@ -20,7 +20,7 @@ The Hub scaffolds three components to `src/components/khotan/`:
|
|
|
20
20
|
|
|
21
21
|
| File | Purpose |
|
|
22
22
|
|------|---------|
|
|
23
|
-
| `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles |
|
|
23
|
+
| `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles, per-flow "Run now" trigger |
|
|
24
24
|
| `var-panel.tsx` | Variables panel for configuring plug vars |
|
|
25
25
|
| `wire-panel.tsx` | Webhook subscription management (connect/disconnect) |
|
|
26
26
|
|
|
@@ -44,6 +44,7 @@ Or use `npx khotan add config-page-1` to scaffold a `/config` page automatically
|
|
|
44
44
|
|
|
45
45
|
- Lists all registered plugs with status badges (connected/error/idle)
|
|
46
46
|
- Click a plug to see its flows with enable/disable toggles
|
|
47
|
+
- "Run now" button on each flow row triggers a tracked run via `POST /api/khotan/flows/:id/runs` (uses the browser session, so it passes your `authorize` hook)
|
|
47
48
|
- VarPanel: configure plug variables (stored encrypted via `KHOTAN_SECRET`)
|
|
48
49
|
- WirePanel: manage webhook subscriptions (requires wires configured on plug)
|
|
49
50
|
- Debug button on each plug card (visible when `KHOTAN_DEBUG=1`)
|
|
@@ -120,7 +120,11 @@ const khotanData = khotan({
|
|
|
120
120
|
|
|
121
121
|
Notes:
|
|
122
122
|
- `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
|
|
123
|
-
encrypts credentials at rest, it does not authenticate requests.
|
|
123
|
+
encrypts credentials at rest, it does not authenticate requests. Conversely,
|
|
124
|
+
`KHOTAN_SECRET` is **not** an HTTP credential: do not send it as a Bearer
|
|
125
|
+
token. Management routes are gated solely by `authorize` (plus the dev-only
|
|
126
|
+
CLI HMAC token). A rejected request returns `401` with `code:
|
|
127
|
+
authorize_rejected` and a `hint` explaining how to authenticate.
|
|
124
128
|
- Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
|
|
125
129
|
the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
|
|
126
130
|
non-production only) are exempt from `authorize` automatically.
|
|
@@ -139,6 +143,76 @@ const nextConfig = {
|
|
|
139
143
|
};
|
|
140
144
|
```
|
|
141
145
|
|
|
146
|
+
## Workflow Runtime & Middleware/Proxy
|
|
147
|
+
|
|
148
|
+
Inflows, outflows, relays, catch, and pass run on **Vercel Workflow**, which
|
|
149
|
+
communicates over `/.well-known/workflow/*`. If your app has a `middleware.ts`
|
|
150
|
+
(or `proxy.ts`) whose `matcher` captures these paths, durable runs **silently
|
|
151
|
+
fail** — steps never get invoked and runs hang.
|
|
152
|
+
|
|
153
|
+
`npx khotan init` detects a middleware/proxy file and warns when it may
|
|
154
|
+
intercept these paths. Exclude them from the matcher:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// middleware.ts
|
|
158
|
+
export const config = {
|
|
159
|
+
matcher: ["/((?!_next|.well-known/workflow).*)"],
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If you do auth or rewrites manually (not via `matcher`), short-circuit early:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
export function middleware(request: NextRequest) {
|
|
167
|
+
if (request.nextUrl.pathname.startsWith("/.well-known/workflow")) {
|
|
168
|
+
return NextResponse.next();
|
|
169
|
+
}
|
|
170
|
+
// ...your logic
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Vercel Workflow also requires AI Gateway OIDC — run `vercel link` and
|
|
175
|
+
`vercel env pull` so `VERCEL_OIDC_TOKEN` is available locally.
|
|
176
|
+
|
|
177
|
+
## Triggering Flows
|
|
178
|
+
|
|
179
|
+
Start a flow through khotan (never call the workflow function directly) so run
|
|
180
|
+
tracking and Workflow IDs are recorded. The API is `khotanData.flow(name).start()`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import khotanData from "@/lib/khotan/khotan";
|
|
184
|
+
|
|
185
|
+
await khotanData.flow("products-inflow", { plugName: "shopify" }).start({
|
|
186
|
+
runType: "delta", // or "full"
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`plugName` is only needed to disambiguate when the same flow name is registered
|
|
191
|
+
under multiple plugs. There is no `khotanData.api.*` or `flow().run()` surface —
|
|
192
|
+
`flow(name).start(options)` is the single entry point for manual and scheduled
|
|
193
|
+
runs alike. The cron dispatcher (`/api/khotan/cron`) calls this same path.
|
|
194
|
+
|
|
195
|
+
### Triggering over HTTP (scripts / external services)
|
|
196
|
+
|
|
197
|
+
There is **no** `POST /flows/:name/run` route. The HTTP trigger is:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
POST /api/khotan/flows/{flowId}/runs body: { "runType": "delta" }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This is a **management route**, so it goes through your `authorize` hook. Common
|
|
204
|
+
gotcha: `KHOTAN_SECRET` is an encryption key, **not** an HTTP credential — sending
|
|
205
|
+
`Authorization: Bearer <KHOTAN_SECRET>` returns `401` with `code: authorize_rejected`.
|
|
206
|
+
To trigger from outside the app, authenticate with a credential your `authorize`
|
|
207
|
+
hook accepts (a session cookie, or your own token you validate inside `authorize`).
|
|
208
|
+
|
|
209
|
+
Prefer triggering server-side with `khotanData.flow(name).start()` whenever you
|
|
210
|
+
can — it needs no HTTP round-trip or auth.
|
|
211
|
+
|
|
212
|
+
The `npx khotan flows trigger <name>` CLI works in **dev** without any of this: it
|
|
213
|
+
signs a short-lived HMAC token from `KHOTAN_SECRET` (the `KhotanCLI` auth scheme,
|
|
214
|
+
disabled when `NODE_ENV=production`). The raw secret never leaves your machine.
|
|
215
|
+
|
|
142
216
|
## Verify Setup
|
|
143
217
|
|
|
144
218
|
```bash
|
|
@@ -194,3 +268,5 @@ This keeps sync logic grounded in real API payloads before you write pagination,
|
|
|
194
268
|
- **"Cannot find module khotan-data"**: Add to `serverExternalPackages` in next.config.ts
|
|
195
269
|
- **Migration fails**: Ensure `DATABASE_URL` is set and Postgres is reachable
|
|
196
270
|
- **Init won't overwrite**: By design — delete the file manually if you need to re-scaffold
|
|
271
|
+
- **Flow/workflow runs hang or never start**: Check your `middleware.ts`/`proxy.ts` matcher excludes `/.well-known/workflow/*` (see "Workflow Runtime & Middleware/Proxy")
|
|
272
|
+
- **Step "is not a function" / fails to resolve at runtime**: Declare `"use step"` functions at module top level and pass `ctx` as an argument — never nest them inside the `"use workflow"` function (closures over workflow scope cannot be hoisted)
|
|
@@ -110,19 +110,28 @@ npx khotan add catch --yes
|
|
|
110
110
|
|
|
111
111
|
Process webhook events durably via Vercel Workflow:
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const processInvoice = catchEvent(async (ctx) => {
|
|
117
|
-
"use workflow";
|
|
113
|
+
Declare `"use step"` functions at module top level and pass `ctx` (serializable
|
|
114
|
+
data) as an argument. Nesting steps inside the `"use workflow"` function fails at
|
|
115
|
+
runtime — closures over workflow scope cannot be hoisted.
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
```typescript
|
|
118
|
+
import { catchEvent, type CatchContext } from "./webhooks/catch";
|
|
119
|
+
import { db } from "@/db";
|
|
120
|
+
import { invoices } from "@/db/schema";
|
|
121
|
+
|
|
122
|
+
// Step: top-level, full Node.js access, retried on failure.
|
|
123
|
+
async function persistInvoice(ctx: CatchContext) {
|
|
124
|
+
"use step";
|
|
125
|
+
await db.insert(invoices).values(ctx.event);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const processInvoice = catchEvent({
|
|
129
|
+
name: "stripe-invoices",
|
|
130
|
+
events: ["invoice.paid"],
|
|
131
|
+
workflow: async (ctx) => {
|
|
132
|
+
"use workflow";
|
|
133
|
+
await persistInvoice(ctx);
|
|
134
|
+
},
|
|
126
135
|
});
|
|
127
136
|
```
|
|
128
137
|
|
|
@@ -140,22 +149,35 @@ npx khotan add pass --yes
|
|
|
140
149
|
|
|
141
150
|
Forward webhook events to another service:
|
|
142
151
|
|
|
152
|
+
The context exposes `ctx.event`, `ctx.eventType`, and `ctx.destVars` (the
|
|
153
|
+
decrypted credentials for the destination plug). There is no `ctx.destPlug` —
|
|
154
|
+
construct the destination plug from `destVars` inside a top-level step.
|
|
155
|
+
|
|
143
156
|
```typescript
|
|
144
|
-
import { pass } from "./webhooks/pass";
|
|
157
|
+
import { pass, type PassContext } from "./webhooks/pass";
|
|
158
|
+
import { plug } from "@/lib/khotan/plugs/plug";
|
|
159
|
+
|
|
160
|
+
// Step: top-level. Build the destination plug from ctx.destVars.
|
|
161
|
+
async function forwardToSlackStep(ctx: PassContext) {
|
|
162
|
+
"use step";
|
|
163
|
+
const slack = plug({
|
|
164
|
+
name: "slack",
|
|
165
|
+
baseUrl: "https://slack.com/api",
|
|
166
|
+
authType: "bearer",
|
|
167
|
+
auth: { bearer: { token: ctx.destVars["botToken"] ?? "" } },
|
|
168
|
+
});
|
|
169
|
+
await slack.post("/chat.postMessage", {
|
|
170
|
+
body: { channel: ctx.destVars["channelId"], text: `New event: ${ctx.eventType}` },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
145
173
|
|
|
146
174
|
const forwardToSlack = pass({
|
|
147
|
-
|
|
175
|
+
name: "stripe-to-slack",
|
|
176
|
+
to: "slack", // Destination plug name (must be registered)
|
|
177
|
+
events: ["invoice.paid"],
|
|
148
178
|
workflow: async (ctx) => {
|
|
149
179
|
"use workflow";
|
|
150
|
-
|
|
151
|
-
// ctx.destVars — destination plug variables
|
|
152
|
-
async function forward() {
|
|
153
|
-
"use step";
|
|
154
|
-
await ctx.destPlug.post("/messages", {
|
|
155
|
-
body: { text: `New event: ${ctx.event.type}` },
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
await forward();
|
|
180
|
+
await forwardToSlackStep(ctx);
|
|
159
181
|
},
|
|
160
182
|
});
|
|
161
183
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "khotan-data",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Data
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Data sync, ETL, and webhook primitives for Next.js + Drizzle + Postgres. shadcn for data plumbing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -107,11 +107,11 @@
|
|
|
107
107
|
"transform",
|
|
108
108
|
"data-management"
|
|
109
109
|
],
|
|
110
|
-
"author": "",
|
|
110
|
+
"author": "Khotan",
|
|
111
111
|
"license": "MIT",
|
|
112
112
|
"repository": {
|
|
113
113
|
"type": "git",
|
|
114
|
-
"url": ""
|
|
114
|
+
"url": "https://github.com/khotan-io/khotan-data"
|
|
115
115
|
},
|
|
116
116
|
"engines": {
|
|
117
117
|
"node": ">=18"
|