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 CHANGED
@@ -4,6 +4,14 @@ Data primitives for TypeScript — ETL pipelines, transforms, and Drizzle Postgr
4
4
 
5
5
  Built for **Next.js + Drizzle + Postgres** projects. Think better-auth for data management.
6
6
 
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i khotan-data
11
+ ```
12
+
13
+ Requires `drizzle-orm` as a peer dependency (you almost certainly already have it).
14
+
7
15
  ## CLI
8
16
 
9
17
  Scaffold components into your Next.js + Drizzle project:
@@ -17,6 +25,7 @@ npx khotan init --full
17
25
 
18
26
  # Add components (reusable building blocks — never create pages)
19
27
  npx khotan add schema # Drizzle table definitions (plugs, flows, runs, resources, mappings)
28
+ npx khotan add cache # Durable key/value caches for workflows and relays
20
29
  npx khotan add plug # Fetch wrapper with auth, retry, pagination
21
30
  npx khotan add inflow # Workflow-backed flow for pulling data in
22
31
  npx khotan add outflow # Workflow-backed flow for pushing data out
@@ -33,18 +42,22 @@ npx khotan add hub --yes # Auto-accept dependency install prompts
33
42
 
34
43
  ## Factory (Runtime Engine)
35
44
 
36
- Register plugs, flows, and resources — the factory upserts them on boot and serves a REST API:
45
+ Register plugs, caches, flows, and resources — the factory upserts them on boot and serves a REST API:
37
46
 
38
47
  ```typescript
39
48
  import { khotan, drizzleAdapter, toNextJsHandler } from "khotan-data/factory";
40
49
  import { db } from "@/db";
41
50
  import { shopifyPlug } from "@/lib/khotan/plugs/shopify";
42
51
  import { shopifyProductsInflow } from "@/lib/khotan/flows/shopify-products";
52
+ import { shopifyProductsSnapshotCache } from "@/lib/khotan/caches/shopify-products-snapshot";
43
53
 
44
54
  const khotanData = khotan({
45
55
  adapter: drizzleAdapter(db),
46
56
  resources: [
47
- { name: "products", connectField: "sku" },
57
+ { name: "products", mapping: { connectField: "sku" } },
58
+ ],
59
+ caches: [
60
+ shopifyProductsSnapshotCache,
48
61
  ],
49
62
  plugs: [
50
63
  {
@@ -66,13 +79,54 @@ await khotanData.flow("products-inflow", { plugName: "shopify" }).start({
66
79
  });
67
80
  ```
68
81
 
69
- ## Install
82
+ ## Caches
70
83
 
71
- ```bash
72
- npm install khotan-data
84
+ Use first-class caches when a flow, relay, catch, or pass needs durable state between runs.
85
+
86
+ ```typescript
87
+ import { cache } from "@/lib/khotan/caches/cache";
88
+
89
+ export const shopifyProductsSnapshotCache = cache({
90
+ name: "shopify-products-snapshot",
91
+ scope: {
92
+ plug: "shopify",
93
+ resource: "products",
94
+ flow: "shopify-products-inflow",
95
+ },
96
+ ttl: "6h",
97
+ });
73
98
  ```
74
99
 
75
- Requires `drizzle-orm` as a peer dependency (you almost certainly already have it).
100
+ Inside workflows, use `khotanCache(ctx, "name")` for snapshots, cursors, and dedupe markers:
101
+
102
+ ```typescript
103
+ import { khotanCache } from "khotan-data/factory";
104
+
105
+ async function shopifyProductsWorkflow(ctx: InflowContext) {
106
+ "use workflow";
107
+
108
+ async function syncProducts() {
109
+ "use step";
110
+ const snapshotCache = khotanCache(ctx, "shopify-products-snapshot");
111
+ const previous =
112
+ (await snapshotCache.get<Array<Record<string, unknown>>>("latest")) ?? [];
113
+
114
+ const response = await shopifyPlug.get<{ data?: Array<Record<string, unknown>> }>("/products");
115
+ const records = Array.isArray(response.data) ? response.data : [];
116
+
117
+ await snapshotCache.set("latest", records);
118
+
119
+ return {
120
+ extracted: records.length,
121
+ transformed: records.length,
122
+ created: records.length,
123
+ metadata: { previousCount: previous.length },
124
+ };
125
+ }
126
+
127
+ return syncProducts();
128
+ }
129
+ ```
76
130
 
77
131
  ## Quick Start
78
132
 
package/dist/cli.js CHANGED
@@ -168,6 +168,23 @@ var COMPONENTS = {
168
168
  npmPackages: ["drizzle-orm"]
169
169
  }
170
170
  },
171
+ cache: {
172
+ name: "cache",
173
+ description: "First-class durable cache definitions for khotan sync workloads",
174
+ requires: ["schema"],
175
+ files: [
176
+ {
177
+ templatePath: path2.resolve(__dirname$1, "templates", "cache.ts"),
178
+ outputFile: "caches/cache.ts",
179
+ outputBase: "outputDir"
180
+ },
181
+ {
182
+ templatePath: path2.resolve(__dirname$1, "templates", "cache.example.ts"),
183
+ outputFile: "caches/cache.example.ts",
184
+ outputBase: "outputDir"
185
+ }
186
+ ]
187
+ },
171
188
  hub: {
172
189
  name: "hub",
173
190
  description: "Dashboard UI for managing plugs and flows",
@@ -231,6 +248,25 @@ var COMPONENTS = {
231
248
  }
232
249
  ]
233
250
  },
251
+ "mapping-browser": {
252
+ name: "mapping-browser",
253
+ description: "Searchable mappings browser for listing, creating, editing, and deleting resource mappings",
254
+ requiresShadcn: true,
255
+ dependencies: {
256
+ shadcnComponents: ["card", "table", "button", "input", "label"]
257
+ },
258
+ files: [
259
+ {
260
+ templatePath: path2.resolve(
261
+ __dirname$1,
262
+ "templates",
263
+ "mapping-browser.tsx"
264
+ ),
265
+ outputFile: "mapping-browser.tsx",
266
+ outputBase: "components"
267
+ }
268
+ ]
269
+ },
234
270
  "plug-debugger": {
235
271
  name: "plug-debugger",
236
272
  description: "Dev-only debug panel for testing plug requests interactively",
@@ -462,6 +498,22 @@ var BLOCKS = {
462
498
  }
463
499
  ]
464
500
  },
501
+ "mappings-page-1": {
502
+ name: "mappings-page-1",
503
+ description: "Page route at /mappings that renders the reusable Khotan mappings browser",
504
+ requires: ["mapping-browser"],
505
+ files: [
506
+ {
507
+ templatePath: path2.resolve(
508
+ __dirname$1,
509
+ "templates",
510
+ "mappings-page.tsx"
511
+ ),
512
+ outputFile: "mappings/page.tsx",
513
+ outputBase: "appRoot"
514
+ }
515
+ ]
516
+ },
465
517
  graph: {
466
518
  name: "graph",
467
519
  description: "Standalone topology graph page at /graph with filtering and run-state overlays",
@@ -1722,13 +1774,13 @@ function diffSchemas(expected, actual, basePath = "$") {
1722
1774
  }
1723
1775
  return diffObjectSchema(expected, actual, basePath);
1724
1776
  }
1725
- function diffTypedNode(expected, actual, path14) {
1777
+ function diffTypedNode(expected, actual, path15) {
1726
1778
  const expectedType = expected["_type"];
1727
1779
  if (expectedType === "array") {
1728
1780
  if (actual.type !== "array") {
1729
1781
  return [
1730
1782
  {
1731
- path: path14,
1783
+ path: path15,
1732
1784
  issue: "type_mismatch",
1733
1785
  note: `expected array, got ${actual.type}`
1734
1786
  }
@@ -1736,13 +1788,13 @@ function diffTypedNode(expected, actual, path14) {
1736
1788
  }
1737
1789
  const itemSchema = expected["items"];
1738
1790
  if (!itemSchema || !actual.items) return [];
1739
- return diffSchemas(itemSchema, actual.items, `${path14}[]`);
1791
+ return diffSchemas(itemSchema, actual.items, `${path15}[]`);
1740
1792
  }
1741
1793
  const normalizedExpected = normalizeType(expectedType);
1742
1794
  if (normalizedExpected !== actual.type && actual.type !== "null") {
1743
1795
  return [
1744
1796
  {
1745
- path: path14,
1797
+ path: path15,
1746
1798
  issue: "type_mismatch",
1747
1799
  note: `expected ${expectedType}, got ${actual.type}`
1748
1800
  }
@@ -1750,11 +1802,11 @@ function diffTypedNode(expected, actual, path14) {
1750
1802
  }
1751
1803
  return [];
1752
1804
  }
1753
- function diffObjectSchema(expected, actual, path14) {
1805
+ function diffObjectSchema(expected, actual, path15) {
1754
1806
  if (actual.type !== "object") {
1755
1807
  return [
1756
1808
  {
1757
- path: path14,
1809
+ path: path15,
1758
1810
  issue: "type_mismatch",
1759
1811
  note: `expected object, got ${actual.type}`
1760
1812
  }
@@ -1763,7 +1815,7 @@ function diffObjectSchema(expected, actual, path14) {
1763
1815
  const mismatches = [];
1764
1816
  const actualProps = actual.properties;
1765
1817
  for (const [key, typeDesc] of Object.entries(expected)) {
1766
- const childPath = path14 === "$" ? `$.${key}` : `${path14}.${key}`;
1818
+ const childPath = path15 === "$" ? `$.${key}` : `${path15}.${key}`;
1767
1819
  const typeStr = typeof typeDesc === "string" ? typeDesc : null;
1768
1820
  const isOptional = typeStr?.endsWith("?") ?? false;
1769
1821
  if (!(key in actualProps)) {
@@ -1801,7 +1853,7 @@ function diffObjectSchema(expected, actual, path14) {
1801
1853
  }
1802
1854
  for (const key of Object.keys(actualProps)) {
1803
1855
  if (!(key in expected)) {
1804
- const childPath = path14 === "$" ? `$.${key}` : `${path14}.${key}`;
1856
+ const childPath = path15 === "$" ? `$.${key}` : `${path15}.${key}`;
1805
1857
  mismatches.push({ path: childPath, issue: "extra" });
1806
1858
  }
1807
1859
  }
@@ -2571,6 +2623,237 @@ withApiOptions(
2571
2623
  );
2572
2624
  output4({ ok: true, action: "cancel", run });
2573
2625
  });
2626
+ function output5(obj) {
2627
+ process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
2628
+ }
2629
+ function fail5(error, hint) {
2630
+ output5({ ok: false, error, hint });
2631
+ process.exit(1);
2632
+ }
2633
+ function parseEnvFile4(filePath) {
2634
+ try {
2635
+ const content = fs4.readFileSync(filePath, "utf-8");
2636
+ const values = {};
2637
+ for (const line of content.split("\n")) {
2638
+ const trimmed = line.trim();
2639
+ if (!trimmed || trimmed.startsWith("#")) continue;
2640
+ const match = /^([A-Z0-9_]+)\s*=\s*["']?(.*?)["']?$/.exec(trimmed);
2641
+ if (match) values[match[1]] = match[2];
2642
+ }
2643
+ return values;
2644
+ } catch {
2645
+ return {};
2646
+ }
2647
+ }
2648
+ function parsePortFromEnvFile5(filePath) {
2649
+ const raw = parseEnvFile4(filePath)["PORT"];
2650
+ if (!raw) return null;
2651
+ const parsed = parseInt(raw, 10);
2652
+ return Number.isFinite(parsed) ? parsed : null;
2653
+ }
2654
+ function resolvePort5(portFlag) {
2655
+ if (portFlag) return parseInt(portFlag, 10);
2656
+ const cwd = process.cwd();
2657
+ return parsePortFromEnvFile5(path2.join(cwd, ".env.local")) ?? parsePortFromEnvFile5(path2.join(cwd, ".env")) ?? 3e3;
2658
+ }
2659
+ function resolveBaseUrl2(opts) {
2660
+ return `http://localhost:${resolvePort5(opts.port)}${opts.basePath}`;
2661
+ }
2662
+ async function fetchJson2(url, init) {
2663
+ const res = await fetch(url, init);
2664
+ const data = await res.json().catch(() => ({}));
2665
+ if (!res.ok) {
2666
+ fail5(
2667
+ "request_failed",
2668
+ data.error ?? `Request to ${url} failed with status ${res.status}`
2669
+ );
2670
+ }
2671
+ return data;
2672
+ }
2673
+ async function checkConnectivity5(baseUrl) {
2674
+ try {
2675
+ const res = await fetch(`${baseUrl}/flows`);
2676
+ if (!res.ok) {
2677
+ fail5(
2678
+ "api_unavailable",
2679
+ `Could not reach Khotan flows API at ${baseUrl}. Check your base path and dev server.`
2680
+ );
2681
+ }
2682
+ } catch {
2683
+ fail5(
2684
+ "connect_failed",
2685
+ `Could not connect to dev server at ${baseUrl.replace("http://", "")}. Is it running?`
2686
+ );
2687
+ }
2688
+ }
2689
+ function parseJsonObjectOption(value, label) {
2690
+ if (!value) return void 0;
2691
+ try {
2692
+ const parsed = JSON.parse(value);
2693
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2694
+ fail5("invalid_json", `${label} must be a JSON object.`);
2695
+ }
2696
+ return parsed;
2697
+ } catch {
2698
+ fail5("invalid_json", `${label} must be valid JSON.`);
2699
+ }
2700
+ }
2701
+ function withApiOptions2(command) {
2702
+ return command.option("--port <port>", "Dev server port").option("--base-path <path>", "API base path", "/api/khotan");
2703
+ }
2704
+ async function listResources(baseUrl) {
2705
+ return fetchJson2(`${baseUrl}/resources`);
2706
+ }
2707
+ async function resolveResource(baseUrl, resourceNameOrId) {
2708
+ const resources = await listResources(baseUrl);
2709
+ const match = resources.find(
2710
+ (resource) => resource.id === resourceNameOrId || resource.name === resourceNameOrId
2711
+ );
2712
+ if (!match) {
2713
+ fail5(
2714
+ "resource_not_found",
2715
+ `Resource "${resourceNameOrId}" is not registered in the running Khotan config.`
2716
+ );
2717
+ }
2718
+ return match;
2719
+ }
2720
+ var mappingsCommand = new Command("mappings").description("List, lookup, and mutate mappings via the running Khotan API");
2721
+ withApiOptions2(
2722
+ mappingsCommand.command("list").description("List mappings for one resource").argument("<resource>", "Resource name or ID").option("--limit <limit>", "Page size", "20").option("--offset <offset>", "Page offset", "0").option("--search <term>", "Search connectValue, refs, and metadata")
2723
+ ).action(
2724
+ async (resourceNameOrId, opts) => {
2725
+ const baseUrl = resolveBaseUrl2(opts);
2726
+ await checkConnectivity5(baseUrl);
2727
+ const resource = await resolveResource(baseUrl, resourceNameOrId);
2728
+ const limit = Math.max(parseInt(opts.limit, 10) || 20, 1);
2729
+ const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
2730
+ const url = new URL(
2731
+ `${baseUrl}/resources/${encodeURIComponent(resource.id)}/mappings`
2732
+ );
2733
+ url.searchParams.set("limit", String(limit));
2734
+ url.searchParams.set("offset", String(offset));
2735
+ if (opts.search) {
2736
+ url.searchParams.set("search", opts.search);
2737
+ }
2738
+ const data = await fetchJson2(url.toString());
2739
+ output5({
2740
+ ok: true,
2741
+ resource,
2742
+ items: data.items,
2743
+ page: data.page
2744
+ });
2745
+ }
2746
+ );
2747
+ withApiOptions2(
2748
+ mappingsCommand.command("lookup").description("Lookup a mapping by connect value or plug ref").argument("<resource>", "Resource name or ID").option("--connect-value <value>", "Lookup by canonical connect value").option("--plug <plugName>", "Lookup by plug name").option("--ref <ref>", "Lookup by plug ref")
2749
+ ).action(
2750
+ async (resourceNameOrId, opts) => {
2751
+ const usingConnectValue = typeof opts.connectValue === "string";
2752
+ const usingPlugRef = typeof opts.plug === "string" || typeof opts.ref === "string";
2753
+ if (!usingConnectValue && !usingPlugRef) {
2754
+ fail5(
2755
+ "validation_error",
2756
+ "Pass either --connect-value <value> or --plug <plugName> with --ref <ref>."
2757
+ );
2758
+ }
2759
+ if (usingConnectValue && usingPlugRef) {
2760
+ fail5(
2761
+ "validation_error",
2762
+ "Choose one lookup mode: either --connect-value or --plug with --ref."
2763
+ );
2764
+ }
2765
+ if (opts.plug && !opts.ref || !opts.plug && opts.ref) {
2766
+ fail5(
2767
+ "validation_error",
2768
+ "Plug-ref lookup requires both --plug <plugName> and --ref <ref>."
2769
+ );
2770
+ }
2771
+ const baseUrl = resolveBaseUrl2(opts);
2772
+ await checkConnectivity5(baseUrl);
2773
+ const resource = await resolveResource(baseUrl, resourceNameOrId);
2774
+ const payload = usingConnectValue ? {
2775
+ resourceId: resource.id,
2776
+ connectValue: opts.connectValue
2777
+ } : {
2778
+ resourceId: resource.id,
2779
+ plugName: opts.plug,
2780
+ ref: opts.ref
2781
+ };
2782
+ const mapping = await fetchJson2(
2783
+ `${baseUrl}/mappings/lookup`,
2784
+ {
2785
+ method: "POST",
2786
+ headers: { "Content-Type": "application/json" },
2787
+ body: JSON.stringify(payload)
2788
+ }
2789
+ );
2790
+ output5({ ok: true, resource, mapping });
2791
+ }
2792
+ );
2793
+ withApiOptions2(
2794
+ mappingsCommand.command("upsert").description("Create or update one mapping by canonical connect value").argument("<resource>", "Resource name or ID").requiredOption("--connect-value <value>", "Canonical connect value").requiredOption("--refs <json>", "JSON object of refs keyed by plug name").option("--metadata <json>", "Optional JSON object of contextual metadata")
2795
+ ).action(
2796
+ async (resourceNameOrId, opts) => {
2797
+ const baseUrl = resolveBaseUrl2(opts);
2798
+ await checkConnectivity5(baseUrl);
2799
+ const resource = await resolveResource(baseUrl, resourceNameOrId);
2800
+ const refs = parseJsonObjectOption(opts.refs, "--refs");
2801
+ const metadata = parseJsonObjectOption(opts.metadata, "--metadata");
2802
+ const mapping = await fetchJson2(`${baseUrl}/mappings`, {
2803
+ method: "POST",
2804
+ headers: { "Content-Type": "application/json" },
2805
+ body: JSON.stringify({
2806
+ resourceId: resource.id,
2807
+ connectValue: opts.connectValue,
2808
+ refs,
2809
+ metadata
2810
+ })
2811
+ });
2812
+ output5({ ok: true, action: "upsert", resource, mapping });
2813
+ }
2814
+ );
2815
+ withApiOptions2(
2816
+ mappingsCommand.command("update").description("Update one mapping by row ID").argument("<mappingId>", "Mapping row ID").requiredOption("--resource <resource>", "Resource name or ID").requiredOption("--connect-value <value>", "Canonical connect value").requiredOption("--refs <json>", "JSON object of refs keyed by plug name").option("--metadata <json>", "Optional JSON object of contextual metadata")
2817
+ ).action(
2818
+ async (mappingId, opts) => {
2819
+ const baseUrl = resolveBaseUrl2(opts);
2820
+ await checkConnectivity5(baseUrl);
2821
+ const resource = await resolveResource(baseUrl, opts.resource);
2822
+ const refs = parseJsonObjectOption(opts.refs, "--refs");
2823
+ const metadata = parseJsonObjectOption(opts.metadata, "--metadata");
2824
+ const mapping = await fetchJson2(
2825
+ `${baseUrl}/mappings/${encodeURIComponent(mappingId)}`,
2826
+ {
2827
+ method: "PUT",
2828
+ headers: { "Content-Type": "application/json" },
2829
+ body: JSON.stringify({
2830
+ resourceId: resource.id,
2831
+ connectValue: opts.connectValue,
2832
+ refs,
2833
+ metadata
2834
+ })
2835
+ }
2836
+ );
2837
+ output5({ ok: true, action: "update", resource, mapping });
2838
+ }
2839
+ );
2840
+ withApiOptions2(
2841
+ mappingsCommand.command("delete").description("Delete one mapping by row ID").argument("<mappingId>", "Mapping row ID")
2842
+ ).action(async (mappingId, opts) => {
2843
+ const baseUrl = resolveBaseUrl2(opts);
2844
+ await checkConnectivity5(baseUrl);
2845
+ const res = await fetch(`${baseUrl}/mappings/${encodeURIComponent(mappingId)}`, {
2846
+ method: "DELETE"
2847
+ });
2848
+ if (!res.ok) {
2849
+ const data = await res.json().catch(() => ({}));
2850
+ fail5(
2851
+ "request_failed",
2852
+ data.error ?? `Delete request failed for mapping "${mappingId}" with status ${res.status}.`
2853
+ );
2854
+ }
2855
+ output5({ ok: true, action: "delete", id: mappingId });
2856
+ });
2574
2857
 
2575
2858
  // src/cli/index.ts
2576
2859
  var program = new Command();
@@ -2582,4 +2865,5 @@ program.addCommand(migrateCommand);
2582
2865
  program.addCommand(plugCommand);
2583
2866
  program.addCommand(wireCommand);
2584
2867
  program.addCommand(flowsCommand);
2868
+ program.addCommand(mappingsCommand);
2585
2869
  program.parse();