@invect/version-control 0.0.1 → 0.0.2

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.
Files changed (52) hide show
  1. package/README.md +78 -0
  2. package/dist/backend/flow-serializer.d.ts.map +1 -1
  3. package/dist/backend/index.cjs +209 -48
  4. package/dist/backend/index.cjs.map +1 -1
  5. package/dist/backend/index.d.cts +8 -3
  6. package/dist/backend/index.d.cts.map +1 -1
  7. package/dist/backend/index.d.mts +8 -3
  8. package/dist/backend/index.d.mts.map +1 -1
  9. package/dist/backend/index.mjs +208 -47
  10. package/dist/backend/index.mjs.map +1 -1
  11. package/dist/backend/plugin.d.ts +2 -2
  12. package/dist/backend/plugin.d.ts.map +1 -1
  13. package/dist/backend/sync-service.d.ts.map +1 -1
  14. package/dist/backend/types.d.ts +5 -0
  15. package/dist/backend/types.d.ts.map +1 -1
  16. package/dist/backend/validation.d.ts +19 -0
  17. package/dist/backend/validation.d.ts.map +1 -0
  18. package/dist/frontend/components/ConnectFlowForm.d.ts +10 -0
  19. package/dist/frontend/components/ConnectFlowForm.d.ts.map +1 -0
  20. package/dist/frontend/components/VcHeaderButton.d.ts +8 -0
  21. package/dist/frontend/components/VcHeaderButton.d.ts.map +1 -0
  22. package/dist/frontend/components/VcSyncPanel.d.ts +10 -0
  23. package/dist/frontend/components/VcSyncPanel.d.ts.map +1 -0
  24. package/dist/frontend/hooks/useFlowSync.d.ts +37 -0
  25. package/dist/frontend/hooks/useFlowSync.d.ts.map +1 -0
  26. package/dist/frontend/index.cjs +717 -0
  27. package/dist/frontend/index.cjs.map +1 -0
  28. package/dist/frontend/index.d.cts +43 -2
  29. package/dist/frontend/index.d.cts.map +1 -0
  30. package/dist/frontend/index.d.mts +43 -2
  31. package/dist/frontend/index.d.mts.map +1 -0
  32. package/dist/frontend/index.d.ts +9 -0
  33. package/dist/frontend/index.d.ts.map +1 -1
  34. package/dist/frontend/index.mjs +705 -1
  35. package/dist/frontend/index.mjs.map +1 -0
  36. package/dist/providers/github.d.cts +1 -1
  37. package/dist/providers/github.d.mts +1 -1
  38. package/dist/shared/types.cjs +19 -0
  39. package/dist/shared/types.cjs.map +1 -0
  40. package/dist/shared/types.d.cts +2 -2
  41. package/dist/shared/types.d.mts +2 -2
  42. package/dist/shared/types.d.ts +4 -2
  43. package/dist/shared/types.d.ts.map +1 -1
  44. package/dist/shared/types.mjs +17 -1
  45. package/dist/shared/types.mjs.map +1 -0
  46. package/dist/{types-B32wGtx7.d.cts → types-DACJdSjJ.d.mts} +6 -4
  47. package/dist/types-DACJdSjJ.d.mts.map +1 -0
  48. package/dist/{types-B7fFBAOX.d.mts → types-DDMnbS1q.d.cts} +6 -4
  49. package/dist/types-DDMnbS1q.d.cts.map +1 -0
  50. package/package.json +31 -4
  51. package/dist/types-B32wGtx7.d.cts.map +0 -1
  52. package/dist/types-B7fFBAOX.d.mts.map +0 -1
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="../../../.github/assets/logo-light.svg">
4
+ <img alt="Invect" src="../../../.github/assets/logo-dark.svg" width="50">
5
+ </picture>
6
+ </p>
7
+
8
+ <h1 align="center">@invect/version-control</h1>
9
+
10
+ <p align="center">
11
+ Version control plugin for Invect.
12
+ <br />
13
+ <a href="https://invect.dev/docs/plugins"><strong>Docs</strong></a>
14
+ </p>
15
+
16
+ ---
17
+
18
+ Sync Invect flows to GitHub (and other Git providers) as readable `.flow.ts` TypeScript files. Supports push, pull, PR-based publishing, and bidirectional sync.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pnpm add @invect/version-control
24
+ ```
25
+
26
+ ## Backend
27
+
28
+ ```ts
29
+ import { versionControl } from '@invect/version-control';
30
+ import { githubProvider } from '@invect/version-control/providers/github';
31
+
32
+ const invectRouter = await createInvectRouter({
33
+ database: { type: 'sqlite', connectionString: 'file:./dev.db' },
34
+ encryptionKey: process.env.INVECT_ENCRYPTION_KEY!,
35
+ plugins: [
36
+ versionControl({
37
+ provider: githubProvider({ auth: process.env.GITHUB_TOKEN! }),
38
+ repo: 'org/my-flows',
39
+ }),
40
+ ],
41
+ });
42
+
43
+ app.use('/invect', invectRouter);
44
+ ```
45
+
46
+ ### Options
47
+
48
+ ```ts
49
+ versionControl({
50
+ provider: githubProvider({ auth: '...' }), // Git hosting provider
51
+ repo: 'owner/repo', // Default repository (owner/name)
52
+ defaultBranch: 'main', // Target branch
53
+ path: 'flows/', // Directory in the repo for flow files
54
+ mode: 'pr-per-publish', // "pr-per-publish" | "auto-sync" | "manual-only"
55
+ syncDirection: 'push', // "push" | "pull" | "bidirectional"
56
+ webhookSecret: '...', // Webhook secret for PR merge events
57
+ });
58
+ ```
59
+
60
+ ## Features
61
+
62
+ - **Push/pull** — Sync flows to and from a Git repository.
63
+ - **PR-based publishing** — Create pull requests for flow changes, merge to deploy.
64
+ - **Bidirectional sync** — Keep flows in sync between Invect and Git.
65
+ - **Readable exports** — Flows are serialized as `.flow.ts` TypeScript files.
66
+ - **Sync history** — Full audit trail of sync operations with commit SHAs.
67
+
68
+ ## Exports
69
+
70
+ | Entry Point | Content |
71
+ | ------------------------------------------ | ------------------------ |
72
+ | `@invect/version-control` | Backend plugin (Node.js) |
73
+ | `@invect/version-control/providers/github` | GitHub provider |
74
+ | `@invect/version-control/types` | Shared types |
75
+
76
+ ## License
77
+
78
+ [MIT](../../../LICENSE)
@@ -1 +1 @@
1
- {"version":3,"file":"flow-serializer.d.ts","sourceRoot":"","sources":["../../src/backend/flow-serializer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,kBAAkB,EAC9B,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAChE,MAAM,CA0ER;AAMD,UAAU,kBAAkB;IAC1B,KAAK,EAAE,KAAK,CAAC;QACX,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE;YAAE,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAChC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,CAAC,CAAC;IACH,KAAK,EAAE,KAAK,CAAC;QACX,EAAE,EAAE,MAAM,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;CACJ"}
1
+ {"version":3,"file":"flow-serializer.d.ts","sourceRoot":"","sources":["../../src/backend/flow-serializer.ts"],"names":[],"mappings":"AAIA;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAC/B,UAAU,EAAE,kBAAkB,EAC9B,QAAQ,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAChE,MAAM,CAiFR;AAMD,UAAU,kBAAkB;IAC1B,KAAK,EAAE,KAAK,CAAC;QACX,EAAE,EAAE,MAAM,CAAC;QACX,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE;YAAE,CAAC,EAAE,MAAM,CAAC;YAAC,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QAChC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAClC,CAAC,CAAC;IACH,KAAK,EAAE,KAAK,CAAC;QACX,EAAE,EAAE,MAAM,CAAC;QACX,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;CACJ"}
@@ -1,5 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_shared_types = require("../shared/types.cjs");
2
3
  let node_crypto = require("node:crypto");
4
+ let zod_v4 = require("zod/v4");
3
5
  //#region src/backend/schema.ts
4
6
  const SYNC_MODES = [
5
7
  "direct-commit",
@@ -208,6 +210,10 @@ function serializeFlowToTs(definition, metadata) {
208
210
  lines.push(" ],");
209
211
  lines.push("});");
210
212
  lines.push("");
213
+ lines.push("/* @invect-definition");
214
+ lines.push(JSON.stringify(definition));
215
+ lines.push("*/");
216
+ lines.push("");
211
217
  return lines.join("\n");
212
218
  }
213
219
  /** Map action IDs to SDK helper function names */
@@ -345,6 +351,11 @@ var VcSyncService = class {
345
351
  }
346
352
  async pushFlow(db, flowId, identity) {
347
353
  const config = await this.requireConfig(db, flowId);
354
+ if (config.syncDirection === "pull") return {
355
+ success: false,
356
+ error: "Push is not allowed — this flow is configured for pull-only sync.",
357
+ action: "push"
358
+ };
348
359
  const { content, version } = await this.exportFlow(db, flowId);
349
360
  try {
350
361
  if (config.mode === "direct-commit") return await this.directCommit(db, config, content, version, identity);
@@ -390,6 +401,11 @@ var VcSyncService = class {
390
401
  }
391
402
  async pullFlow(db, flowId, identity) {
392
403
  const config = await this.requireConfig(db, flowId);
404
+ if (config.syncDirection === "push") return {
405
+ success: false,
406
+ error: "Pull is not allowed — this flow is configured for push-only sync.",
407
+ action: "pull"
408
+ };
393
409
  const remote = await this.provider.getFileContent(config.repo, config.filePath, config.branch);
394
410
  if (!remote) return {
395
411
  success: false,
@@ -624,7 +640,7 @@ var VcSyncService = class {
624
640
  const versions = await db.query("SELECT flow_id, version, invect_definition FROM flow_versions WHERE flow_id = ? ORDER BY version DESC LIMIT 1", [flowId]);
625
641
  if (versions.length === 0) throw new Error(`No versions found for flow: ${flowId}`);
626
642
  const fv = versions[0];
627
- const definition = typeof fv.invectDefinition === "string" ? JSON.parse(fv.invectDefinition) : fv.invectDefinition;
643
+ const definition = typeof fv.invect_definition === "string" ? JSON.parse(fv.invect_definition) : fv.invect_definition;
628
644
  let tags;
629
645
  if (flow.tags) try {
630
646
  tags = typeof flow.tags === "string" ? JSON.parse(flow.tags) : flow.tags;
@@ -641,43 +657,27 @@ var VcSyncService = class {
641
657
  };
642
658
  }
643
659
  async importFlowContent(db, flowId, content, identity) {
644
- const { writeFileSync, unlinkSync, mkdtempSync } = await import("node:fs");
645
- const { join } = await import("node:path");
646
- const { tmpdir } = await import("node:os");
647
- const tmpDir = mkdtempSync(join(tmpdir(), "invect-vc-"));
648
- const tmpFile = join(tmpDir, "import.flow.ts");
649
- try {
650
- writeFileSync(tmpFile, content, "utf-8");
651
- const { createJiti } = await import("jiti");
652
- const result = await createJiti(require("url").pathToFileURL(__filename).href, { interopDefault: true }).import(tmpFile);
653
- const definition = result.default ?? result;
654
- if (!definition || typeof definition !== "object" || !("nodes" in definition) || !("edges" in definition)) throw new Error("Imported .flow.ts file did not produce a valid InvectDefinition. Expected an object with \"nodes\" and \"edges\" arrays.");
655
- const nextVersion = ((await db.query("SELECT MAX(version) as version FROM flow_versions WHERE flow_id = ?", [flowId]))[0]?.version ?? 0) + 1;
656
- const defJson = typeof definition === "string" ? definition : JSON.stringify(definition);
657
- await db.execute(`INSERT INTO flow_versions (flow_id, version, invect_definition, created_at, created_by)
658
- VALUES (?, ?, ?, ?, ?)`, [
659
- flowId,
660
- nextVersion,
661
- defJson,
662
- (/* @__PURE__ */ new Date()).toISOString(),
663
- identity ?? null
664
- ]);
665
- await db.execute("UPDATE flows SET live_version_number = ?, updated_at = ? WHERE id = ?", [
666
- nextVersion,
667
- (/* @__PURE__ */ new Date()).toISOString(),
668
- flowId
669
- ]);
670
- this.logger.info("Flow imported from remote", {
671
- flowId,
672
- version: nextVersion
673
- });
674
- } finally {
675
- try {
676
- unlinkSync(tmpFile);
677
- const { rmdirSync } = await import("node:fs");
678
- rmdirSync(tmpDir);
679
- } catch {}
680
- }
660
+ const definition = parseFlowTsContent(content);
661
+ if (!definition || typeof definition !== "object" || !Array.isArray(definition.nodes) || !Array.isArray(definition.edges)) throw new Error("Imported .flow.ts file did not produce a valid InvectDefinition. Expected an object with \"nodes\" and \"edges\" arrays.");
662
+ const nextVersion = ((await db.query("SELECT MAX(version) as version FROM flow_versions WHERE flow_id = ?", [flowId]))[0]?.version ?? 0) + 1;
663
+ const defJson = JSON.stringify(definition);
664
+ await db.execute(`INSERT INTO flow_versions (flow_id, version, invect_definition, created_at, created_by)
665
+ VALUES (?, ?, ?, ?, ?)`, [
666
+ flowId,
667
+ nextVersion,
668
+ defJson,
669
+ (/* @__PURE__ */ new Date()).toISOString(),
670
+ identity ?? null
671
+ ]);
672
+ await db.execute("UPDATE flows SET live_version_number = ?, updated_at = ? WHERE id = ?", [
673
+ nextVersion,
674
+ (/* @__PURE__ */ new Date()).toISOString(),
675
+ flowId
676
+ ]);
677
+ this.logger.info("Flow imported from remote", {
678
+ flowId,
679
+ version: nextVersion
680
+ });
681
681
  }
682
682
  buildFilePath(flowName) {
683
683
  return `${this.options.path ?? "workflows/"}${flowName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}.flow.ts`;
@@ -757,6 +757,153 @@ function mapHistoryRow(r) {
757
757
  createdBy: r.created_by
758
758
  };
759
759
  }
760
+ /**
761
+ * Parse a .flow.ts file content to extract the InvectDefinition.
762
+ *
763
+ * This is a static parser that does NOT evaluate the TypeScript file.
764
+ * It works by extracting the `defineFlow({ ... })` call's argument as a
765
+ * JS object literal string and parsing it with a safe JSON5-like approach.
766
+ *
767
+ * Falls back to extracting raw `nodes` and `edges` arrays if defineFlow
768
+ * wrapper is not found.
769
+ */
770
+ function parseFlowTsContent(content) {
771
+ const jsonCommentMatch = content.match(/\/\*\s*@invect-definition\s+([\s\S]*?)\s*\*\//);
772
+ if (jsonCommentMatch) try {
773
+ return JSON.parse(jsonCommentMatch[1]);
774
+ } catch {}
775
+ const defineFlowMatch = content.match(/defineFlow\s*\(\s*\{/);
776
+ if (defineFlowMatch && defineFlowMatch.index !== void 0) {
777
+ const objStr = extractBalancedBraces(content, defineFlowMatch.index + defineFlowMatch[0].length - 1);
778
+ if (objStr) try {
779
+ const parsed = parseObjectLiteral(objStr);
780
+ if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) return {
781
+ nodes: parsed.nodes,
782
+ edges: parsed.edges
783
+ };
784
+ } catch {}
785
+ }
786
+ return null;
787
+ }
788
+ /** Extract a balanced {} block from a string starting at the given { index */
789
+ function extractBalancedBraces(str, startIdx) {
790
+ let depth = 0;
791
+ let inString = false;
792
+ let escaped = false;
793
+ for (let i = startIdx; i < str.length; i++) {
794
+ const ch = str[i];
795
+ if (escaped) {
796
+ escaped = false;
797
+ continue;
798
+ }
799
+ if (ch === "\\") {
800
+ escaped = true;
801
+ continue;
802
+ }
803
+ if (inString) {
804
+ if (ch === inString) inString = false;
805
+ continue;
806
+ }
807
+ if (ch === "\"" || ch === "'" || ch === "`") {
808
+ inString = ch;
809
+ continue;
810
+ }
811
+ if (ch === "{" || ch === "[") depth++;
812
+ else if (ch === "}" || ch === "]") {
813
+ depth--;
814
+ if (depth === 0) return str.slice(startIdx, i + 1);
815
+ }
816
+ }
817
+ return null;
818
+ }
819
+ /**
820
+ * Parse a JS object literal string into a JSON-compatible value.
821
+ *
822
+ * Handles: unquoted keys, single-quoted strings, trailing commas,
823
+ * template literals (simplified), and function calls by converting
824
+ * them to strings.
825
+ */
826
+ function parseObjectLiteral(objStr) {
827
+ try {
828
+ let normalized = objStr.replace(/\/\/.*$/gm, "");
829
+ normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, "");
830
+ normalized = replaceQuotes(normalized);
831
+ normalized = normalized.replace(/(?<=[{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)(?=\s*:)/g, "\"$1\"");
832
+ normalized = normalized.replace(/,\s*([}\]])/g, "$1");
833
+ normalized = replaceFunctionCalls(normalized);
834
+ return JSON.parse(normalized);
835
+ } catch {
836
+ return null;
837
+ }
838
+ }
839
+ /** Replace single-quoted strings with double-quoted */
840
+ function replaceQuotes(str) {
841
+ let result = "";
842
+ let inDouble = false;
843
+ let inSingle = false;
844
+ let escaped = false;
845
+ for (let i = 0; i < str.length; i++) {
846
+ const ch = str[i];
847
+ if (escaped) {
848
+ result += ch;
849
+ escaped = false;
850
+ continue;
851
+ }
852
+ if (ch === "\\") {
853
+ result += ch;
854
+ escaped = true;
855
+ continue;
856
+ }
857
+ if (!inSingle && ch === "\"") {
858
+ inDouble = !inDouble;
859
+ result += ch;
860
+ } else if (!inDouble && ch === "'") {
861
+ inSingle = !inSingle;
862
+ result += "\"";
863
+ } else result += ch;
864
+ }
865
+ return result;
866
+ }
867
+ /**
868
+ * Replace function calls like `input("ref", { ... })` with a JSON object
869
+ * that captures the node structure. This handles the SDK helper calls
870
+ * in the serialized .flow.ts nodes array.
871
+ *
872
+ * Pattern: `helperName("refId", { params })` → `{ "type": "helperName", "referenceId": "refId", "params": { ... } }`
873
+ * Also handles namespaced: `ns.helperName("refId", { ... })`
874
+ */
875
+ function replaceFunctionCalls(str) {
876
+ const callPattern = /([a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*|[a-zA-Z_$][\w$]*)\s*\(\s*"([^"]*)"\s*,\s*(\{)/g;
877
+ let result = str;
878
+ let match;
879
+ let offset = 0;
880
+ callPattern.lastIndex = 0;
881
+ while ((match = callPattern.exec(str)) !== null) {
882
+ const fnName = match[1];
883
+ const refId = match[2];
884
+ const braceStart = match.index + match[0].length - 1;
885
+ const paramsBlock = extractBalancedBraces(str, braceStart);
886
+ if (!paramsBlock) continue;
887
+ let closeParen = braceStart + paramsBlock.length;
888
+ while (closeParen < str.length && str[closeParen] !== ")") closeParen++;
889
+ const fullCall = str.slice(match.index, closeParen + 1);
890
+ const replacement = `{ "__type": "${fnName}", "referenceId": "${refId}", "params": ${paramsBlock} }`;
891
+ result = result.slice(0, match.index + offset) + replacement + result.slice(match.index + offset + fullCall.length);
892
+ offset += replacement.length - fullCall.length;
893
+ }
894
+ return result;
895
+ }
896
+ //#endregion
897
+ //#region src/backend/validation.ts
898
+ const configureSyncInputSchema = zod_v4.z.object({
899
+ repo: zod_v4.z.string().regex(/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/, "Invalid repo format. Expected \"owner/name\".").optional(),
900
+ branch: zod_v4.z.string().max(256).regex(/^[a-zA-Z0-9._/-]+$/, "Invalid branch name.").optional(),
901
+ filePath: zod_v4.z.string().max(1024).regex(/^[a-zA-Z0-9._/-]+\.flow\.ts$/, "File path must end with .flow.ts").optional(),
902
+ mode: zod_v4.z.enum(require_shared_types.VC_SYNC_MODES).optional(),
903
+ syncDirection: zod_v4.z.enum(require_shared_types.VC_SYNC_DIRECTIONS).optional(),
904
+ enabled: zod_v4.z.boolean().optional()
905
+ });
906
+ const historyLimitSchema = zod_v4.z.coerce.number().int().min(1).max(100).default(20);
760
907
  //#endregion
761
908
  //#region src/backend/plugin.ts
762
909
  /**
@@ -780,6 +927,15 @@ function mapHistoryRow(r) {
780
927
  * ```
781
928
  */
782
929
  function versionControl(options) {
930
+ const { frontend, ...backendOptions } = options;
931
+ return {
932
+ id: "version-control",
933
+ name: "Version Control",
934
+ backend: _vcBackendPlugin(backendOptions),
935
+ frontend
936
+ };
937
+ }
938
+ function _vcBackendPlugin(options) {
783
939
  let syncService;
784
940
  let pluginLogger = console;
785
941
  return {
@@ -798,10 +954,17 @@ function versionControl(options) {
798
954
  path: "/vc/flows/:flowId/configure",
799
955
  handler: async (ctx) => {
800
956
  const { flowId } = ctx.params;
801
- const input = ctx.body;
957
+ const parsed = configureSyncInputSchema.safeParse(ctx.body);
958
+ if (!parsed.success) return {
959
+ status: 400,
960
+ body: {
961
+ error: "Invalid input",
962
+ details: parsed.error.issues
963
+ }
964
+ };
802
965
  return {
803
966
  status: 200,
804
- body: await syncService.configureSyncForFlow(ctx.database, flowId, input)
967
+ body: await syncService.configureSyncForFlow(ctx.database, flowId, parsed.data)
805
968
  };
806
969
  }
807
970
  },
@@ -998,7 +1161,7 @@ function versionControl(options) {
998
1161
  path: "/vc/flows/:flowId/history",
999
1162
  handler: async (ctx) => {
1000
1163
  const { flowId } = ctx.params;
1001
- const limit = ctx.query.limit ? parseInt(ctx.query.limit, 10) : 20;
1164
+ const limit = historyLimitSchema.parse(ctx.query.limit);
1002
1165
  return {
1003
1166
  status: 200,
1004
1167
  body: {
@@ -1012,25 +1175,23 @@ function versionControl(options) {
1012
1175
  hooks: {}
1013
1176
  };
1014
1177
  async function handlePrMerged(db, prNumber) {
1015
- const rows = await db.query("SELECT flow_id FROM vc_sync_config WHERE active_pr_number = ?", [prNumber]);
1178
+ const rows = await db.query("SELECT flow_id, draft_branch, repo FROM vc_sync_config WHERE active_pr_number = ?", [prNumber]);
1016
1179
  for (const row of rows) {
1180
+ if (row.draft_branch) try {
1181
+ await options.provider.deleteBranch(row.repo, row.draft_branch);
1182
+ } catch {}
1017
1183
  await db.execute(`UPDATE vc_sync_config
1018
1184
  SET active_pr_number = NULL, active_pr_url = NULL, draft_branch = NULL, updated_at = ?
1019
1185
  WHERE flow_id = ?`, [(/* @__PURE__ */ new Date()).toISOString(), row.flow_id]);
1020
- const { randomUUID } = await import("node:crypto");
1021
1186
  await db.execute(`INSERT INTO vc_sync_history (id, flow_id, action, pr_number, message, created_at)
1022
1187
  VALUES (?, ?, ?, ?, ?, ?)`, [
1023
- randomUUID(),
1188
+ (0, node_crypto.randomUUID)(),
1024
1189
  row.flow_id,
1025
1190
  "pr-merged",
1026
1191
  prNumber,
1027
1192
  `PR #${prNumber} merged`,
1028
1193
  (/* @__PURE__ */ new Date()).toISOString()
1029
1194
  ]);
1030
- const configs = await db.query("SELECT draft_branch, repo FROM vc_sync_config WHERE flow_id = ?", [row.flow_id]);
1031
- if (configs[0]?.draft_branch) try {
1032
- await options.provider.deleteBranch(configs[0].repo, configs[0].draft_branch);
1033
- } catch {}
1034
1195
  pluginLogger.info("PR merged — sync updated", {
1035
1196
  flowId: row.flow_id,
1036
1197
  prNumber