@jxsuite/studio 0.37.0 → 1.0.0

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 (38) hide show
  1. package/dist/studio.js +77654 -76269
  2. package/dist/studio.js.map +128 -119
  3. package/package.json +44 -44
  4. package/src/account-status.ts +39 -0
  5. package/src/browse/browse.ts +10 -14
  6. package/src/canvas/iframe-host.ts +1 -1
  7. package/src/editor/context-menu.ts +1 -1
  8. package/src/editor/repeater-scope.ts +8 -13
  9. package/src/files/files.ts +3 -0
  10. package/src/format/format-host.ts +63 -1
  11. package/src/new-project/add-repo-modal.ts +183 -0
  12. package/src/new-project/new-project-modal.ts +22 -3
  13. package/src/page-params.ts +34 -8
  14. package/src/panels/ai-chat/chat-markdown.ts +2 -2
  15. package/src/panels/ai-panel.ts +61 -4
  16. package/src/panels/data-grid.ts +619 -0
  17. package/src/panels/signals-panel.ts +102 -437
  18. package/src/panels/statusbar.ts +1 -1
  19. package/src/panels/welcome-screen.ts +50 -0
  20. package/src/platform-errors.ts +30 -0
  21. package/src/platforms/cloud.ts +172 -89
  22. package/src/platforms/devserver.ts +172 -0
  23. package/src/services/context-resolver.ts +73 -0
  24. package/src/services/data-service.ts +155 -0
  25. package/src/services/monaco-setup.ts +75 -26
  26. package/src/settings/contributed-section.ts +406 -0
  27. package/src/settings/extension-sections.ts +145 -0
  28. package/src/settings/schema-field-ui.ts +4 -2
  29. package/src/settings/settings-modal.ts +101 -42
  30. package/src/site-context.ts +10 -1
  31. package/src/studio.ts +24 -0
  32. package/src/tabs/transact.ts +1 -1
  33. package/src/types.ts +120 -1
  34. package/src/ui/form-controls.ts +322 -0
  35. package/src/ui/progress-modal.ts +2 -2
  36. package/src/ui/schema-form.ts +524 -0
  37. package/src/utils/studio-utils.ts +4 -3
  38. package/src/settings/content-types-editor.ts +0 -599
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import { html, render as litRender, nothing } from "lit-html";
8
+ import { getAccountStatus, needsAppInstall } from "../account-status";
9
+ import { platformSupportsAddRepo } from "../new-project/add-repo-modal";
8
10
  import { getProjectList } from "../project-list";
9
11
  import { clearRecentProjects, getRecentProjects, removeRecentProject } from "../recent-projects";
10
12
  import { renderOnly } from "../store";
@@ -15,6 +17,7 @@ interface WelcomeCtx {
15
17
  openRecentProject: (root: string) => void;
16
18
  openNewProject: () => void;
17
19
  cloneRepository: () => void;
20
+ addExistingRepo: () => void;
18
21
  }
19
22
 
20
23
  let _ctx: WelcomeCtx | null = null;
@@ -31,6 +34,7 @@ export function renderWelcome(host: HTMLElement) {
31
34
  // Catalogue entries already in Recent stay in that section only.
32
35
  const catalogue = getProjectList().filter((p) => !recent.some((r) => r.root === p.root));
33
36
  const showClone = platformSupportsClone();
37
+ const showAddRepo = platformSupportsAddRepo();
34
38
 
35
39
  litRender(
36
40
  html`
@@ -85,8 +89,54 @@ export function renderWelcome(host: HTMLElement) {
85
89
  Clone Git Repository...
86
90
  </button>`
87
91
  : nothing}
92
+ ${showAddRepo
93
+ ? html`<button class="welcome-action" @click=${() => ctx.addExistingRepo()}>
94
+ <svg
95
+ class="welcome-action-icon"
96
+ width="16"
97
+ height="16"
98
+ viewBox="0 0 16 16"
99
+ fill="currentColor"
100
+ >
101
+ <path
102
+ d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.5 2.5 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.25.25 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z"
103
+ />
104
+ </svg>
105
+ Add Existing Repository...
106
+ </button>`
107
+ : nothing}
88
108
  </div>
89
109
 
110
+ ${needsAppInstall()
111
+ ? html`
112
+ <div class="welcome-section">
113
+ <h2 class="welcome-section-title">Repository access</h2>
114
+ <a
115
+ class="welcome-action"
116
+ href=${getAccountStatus()?.appInstallUrl ?? "#"}
117
+ target="_blank"
118
+ rel="noreferrer"
119
+ >
120
+ <svg
121
+ class="welcome-action-icon"
122
+ width="16"
123
+ height="16"
124
+ viewBox="0 0 16 16"
125
+ fill="currentColor"
126
+ >
127
+ <path
128
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"
129
+ />
130
+ </svg>
131
+ Install the Jx Suite GitHub App
132
+ </a>
133
+ <p class="welcome-install-note">
134
+ Grants repository access so you can create and open projects — choose “All
135
+ repositories”.
136
+ </p>
137
+ </div>
138
+ `
139
+ : nothing}
90
140
  ${catalogue.length > 0
91
141
  ? html`
92
142
  <div class="welcome-section">
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Structured platform errors. Backend routes attach machine-readable fields to failures (e.g.
3
+ * project creation blocked by missing GitHub App installation access → `code:
4
+ * "needs_installation_access"` + `installUrl`); adapters preserve them on the thrown Error via
5
+ * `Object.assign`, and UI surfaces recover them here to render actions (an install link) instead of
6
+ * plain text.
7
+ */
8
+
9
+ export interface PlatformErrorInfo {
10
+ code?: string;
11
+ installUrl?: string;
12
+ }
13
+
14
+ /** Machine-readable fields carried by a thrown platform error (empty object when none). */
15
+ export function platformErrorInfo(error: unknown): PlatformErrorInfo {
16
+ if (!error || typeof error !== "object") {
17
+ return {};
18
+ }
19
+ const { code, installUrl } = error as { code?: unknown; installUrl?: unknown };
20
+ return {
21
+ ...(typeof code === "string" ? { code } : {}),
22
+ ...(typeof installUrl === "string" ? { installUrl } : {}),
23
+ };
24
+ }
25
+
26
+ /** The GitHub-App install link when the error is the structured needs-installation 403. */
27
+ export function installUrlOf(error: unknown): string | null {
28
+ const info = platformErrorInfo(error);
29
+ return info.code === "needs_installation_access" && info.installUrl ? info.installUrl : null;
30
+ }
@@ -11,15 +11,11 @@
11
11
  * gitClone, resolveClass, component discovery, code services.
12
12
  */
13
13
 
14
- /* Import the browser-safe primitives directly (NOT the Markdown class —
15
- its node-only discover/load chain drags glob/node:fs into the bundle). */
16
- import { transpileJxMarkdown } from "@jxsuite/parser/transpile";
17
- import { serializeJxMarkdown } from "@jxsuite/parser/serialize";
18
- import markdownClassDef from "@jxsuite/parser/Markdown.class.json";
19
14
  import type { WsCollabConnection } from "@jxsuite/collab/client";
20
- import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
15
+ import type { ProjectConfig } from "@jxsuite/schema/types";
21
16
  import type {
22
17
  DirEntry,
18
+ ExtensionsInfo,
23
19
  FsEvent,
24
20
  GitBranchesResult,
25
21
  GitLogEntry,
@@ -27,6 +23,7 @@ import type {
27
23
  PackageInfo,
28
24
  ProjectListEntry,
29
25
  RenameResult,
26
+ RepoInfo,
30
27
  StarterInfo,
31
28
  StudioPlatform,
32
29
  } from "../types";
@@ -74,47 +71,6 @@ async function okJson<T>(res: Response, fallback: string): Promise<T> {
74
71
  return (await res.json()) as T;
75
72
  }
76
73
 
77
- /**
78
- * Formats the cloud can execute: browser-safe built-ins only (parse/serialize run IN the editor
79
- * page via @jxsuite/parser — the platform never executes project JS). The registry entry mirrors
80
- * the devserver's `GET /__studio/formats` shape, sourced from the canonical class descriptor.
81
- * Unlike the devserver we register Markdown regardless of project.json imports — the compiler still
82
- * requires the import for `jx build`, but the editor should never strand a .md file unopenable.
83
- */
84
- interface MarkdownClassDef {
85
- format: {
86
- extensions: string[];
87
- mediaType: string;
88
- documentKinds: string[];
89
- exportTarget: boolean;
90
- remote: boolean;
91
- };
92
- $studio: Record<string, unknown>;
93
- }
94
-
95
- function builtinFormats(config: ProjectConfig | null): Record<string, unknown>[] {
96
- const def = markdownClassDef as unknown as MarkdownClassDef;
97
- const imports = (config?.imports ?? {}) as Record<string, string>;
98
- const importedName = Object.keys(imports).find((name) =>
99
- imports[name]?.includes("Markdown.class.json"),
100
- );
101
- return [
102
- {
103
- name: importedName ?? "Markdown",
104
- extensions: def.format.extensions,
105
- mediaType: def.format.mediaType,
106
- documentKinds: def.format.documentKinds,
107
- exportTarget: def.format.exportTarget,
108
- remote: def.format.remote,
109
- studio: def.$studio,
110
- capabilities: {
111
- parse: { identifier: "parse", timing: ["compiler", "server", "client"] },
112
- serialize: { identifier: "serialize", timing: ["compiler", "server", "client"] },
113
- },
114
- },
115
- ];
116
- }
117
-
118
74
  /** Editor URL for a project session (mirrors the shell's route). */
119
75
  export function editUrl(project: CloudProject): string {
120
76
  return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
@@ -177,6 +133,8 @@ async function fetchCfConnection(): Promise<{
177
133
  };
178
134
  }
179
135
 
136
+ type CfConnectionInfo = Awaited<ReturnType<typeof fetchCfConnection>>;
137
+
180
138
  /** Parse an "owner/repo@branch" root key; null when malformed. */
181
139
  export function parseRootKey(root: string): CloudProject | null {
182
140
  const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
@@ -247,8 +205,6 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
247
205
  }
248
206
  }
249
207
 
250
- let cachedConfig: ProjectConfig | null = null;
251
-
252
208
  const platform: StudioPlatform = {
253
209
  id: "cloud",
254
210
  projectRoot: root,
@@ -415,34 +371,59 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
415
371
  };
416
372
  },
417
373
 
418
- // ─── Formats (browser-safe built-ins, executed in-page) ───────────────
374
+ // ─── Formats (session backend registry, mirroring the dev-server seam)
419
375
 
376
+ /**
377
+ * The project's format registry, served by the session gateway (the dev server's `GET
378
+ * /__studio/formats` under this session's base path). Part-4 cleanup: the cloud ProjectSession
379
+ * does not serve the route yet, so this degrades to an empty registry (only .json documents
380
+ * open) until the backend lands it.
381
+ */
420
382
  async listFormats() {
421
- if (cachedConfig === null) {
422
- try {
423
- const info = await projectInfo();
424
- cachedConfig = (info.projectConfig as ProjectConfig | null) ?? null;
425
- } catch {
426
- cachedConfig = null;
383
+ try {
384
+ const res = await api("/formats");
385
+ if (!res.ok) {
386
+ return [];
427
387
  }
388
+ const body = (await res.json()) as { formats?: Record<string, unknown>[] };
389
+ return body.formats ?? [];
390
+ } catch {
391
+ return [];
428
392
  }
429
- return builtinFormats(cachedConfig);
430
393
  },
431
394
 
432
- async formatAction(payload: Record<string, unknown>) {
433
- const { action, source, doc, options } = payload as {
434
- action: string;
435
- source?: string;
436
- doc?: Record<string, unknown>;
437
- options?: Record<string, unknown>;
438
- };
439
- if (action === "parse") {
440
- return transpileJxMarkdown(source ?? "");
395
+ /** The extensions payload riding beside `formats` on the same route. */
396
+ async listExtensions(): Promise<ExtensionsInfo[]> {
397
+ try {
398
+ const res = await api("/formats");
399
+ if (!res.ok) {
400
+ return [];
401
+ }
402
+ const body = (await res.json()) as { extensions?: ExtensionsInfo[] };
403
+ return body.extensions ?? [];
404
+ } catch {
405
+ return [];
441
406
  }
442
- if (action === "serialize") {
443
- return serializeJxMarkdown((doc ?? {}) as JxDocument, options);
407
+ },
408
+
409
+ /**
410
+ * Invoke a format capability (parse/serialize) on the session backend — the dev server's `POST
411
+ * /__studio/format` seam. In-page execution left with the core → @jxsuite/parser dependency, so
412
+ * backends without the route surface the error below instead of silently mangling format
413
+ * documents.
414
+ */
415
+ async formatAction(payload: Record<string, unknown>) {
416
+ const res = await postJson("/format", payload);
417
+ if (!res.ok) {
418
+ throw new Error(
419
+ await errorMessage(
420
+ res,
421
+ "This cloud session cannot run format actions yet (the backend serves no format route)",
422
+ ),
423
+ );
444
424
  }
445
- throw new Error(`Unsupported format action: ${action}`);
425
+ const data = (await res.json()) as { result?: unknown };
426
+ return data.result;
446
427
  },
447
428
 
448
429
  // ─── Components / code services (cloud: static-only posture) ──────────
@@ -692,10 +673,18 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
692
673
  starter: opts.starter,
693
674
  }),
694
675
  });
695
- const created = await okJson<{ owner: string; name: string; defaultBranch: string }>(
696
- res,
697
- "Failed to create project",
698
- );
676
+ if (!res.ok) {
677
+ // Preserve the structured 403 (needs_installation_access + installUrl) so the New
678
+ // Project modal can render an install link instead of flattened text.
679
+ const body = (await res.json().catch(() => null)) as
680
+ | (ErrorBody & { installUrl?: string })
681
+ | null;
682
+ throw Object.assign(new Error(body?.error ?? "Failed to create project"), {
683
+ ...(body?.code ? { code: body.code } : {}),
684
+ ...(body?.installUrl ? { installUrl: body.installUrl } : {}),
685
+ });
686
+ }
687
+ const created = (await res.json()) as { owner: string; name: string; defaultBranch: string };
699
688
  return {
700
689
  root: projectRootKey({
701
690
  owner: created.owner,
@@ -726,6 +715,61 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
726
715
  };
727
716
  },
728
717
 
718
+ /** Repositories reachable through the user's Jx Suite App installations (user + org). */
719
+ async listRepos(): Promise<RepoInfo[]> {
720
+ const res = await fetch("/api/v1/repos", { credentials: "include" });
721
+ if (!res.ok) {
722
+ throw new Error(await errorMessage(res, "Failed to list repositories"));
723
+ }
724
+ const entries = (await res.json()) as (RepoInfo & { repoId: number })[];
725
+ return entries.map((r) => ({
726
+ owner: r.owner,
727
+ name: r.name,
728
+ fullName: r.fullName,
729
+ private: r.private,
730
+ defaultBranch: r.defaultBranch,
731
+ permission: r.permission,
732
+ isJxProject: r.isJxProject,
733
+ }));
734
+ },
735
+
736
+ /** Adopt an existing repo as a Jx project; resolves to its catalogue root key. */
737
+ async importProject(opts: { owner: string; name: string }) {
738
+ const res = await fetch("/api/v1/projects/import", {
739
+ method: "POST",
740
+ credentials: "include",
741
+ headers: { "Content-Type": "application/json" },
742
+ body: JSON.stringify(opts),
743
+ });
744
+ const imported = await okJson<{ owner: string; name: string; defaultBranch: string }>(
745
+ res,
746
+ "Failed to import repository",
747
+ );
748
+ return {
749
+ root: projectRootKey({
750
+ owner: imported.owner,
751
+ repo: imported.name,
752
+ branch: imported.defaultBranch,
753
+ }),
754
+ };
755
+ },
756
+
757
+ /** GitHub-App installation coverage from /me — powers the welcome install prompt. */
758
+ async getAccountStatus() {
759
+ const res = await fetch("/api/v1/me", { credentials: "include" });
760
+ if (!res.ok) {
761
+ return null;
762
+ }
763
+ const me = (await res.json()) as {
764
+ installations?: { id: number; account: string | null }[];
765
+ appInstallUrl?: string;
766
+ };
767
+ return {
768
+ installations: me.installations ?? [],
769
+ ...(me.appInstallUrl ? { appInstallUrl: me.appInstallUrl } : {}),
770
+ };
771
+ },
772
+
729
773
  /** Open a PR from this session's branch (ProjectSession /git/pr). */
730
774
  async createPullRequest(opts: { title: string; body?: string; head?: string; base?: string }) {
731
775
  return okJson<{ url: string; number: number }>(
@@ -739,8 +783,11 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
739
783
  },
740
784
 
741
785
  /**
742
- * Hosted OAuth connect: open the broker flow in a popup and poll until the connection lands (or
743
- * the popup closes / times out). Popup-blocked browsers fall back to a full-page redirect.
786
+ * Hosted OAuth connect: open the broker flow in a popup. The home shell relays the callback's
787
+ * result back via postMessage {source: "jx-cf"} and closes the popup, so success resolves and
788
+ * OAuth errors (denial, invalid_scope misregistration) REJECT with the real reason instead of
789
+ * timing out. A 1.5s poll remains as fallback for older shells / blocked message delivery, and
790
+ * popup-blocked browsers fall back to a full-page redirect.
744
791
  */
745
792
  async cfConnect() {
746
793
  if (typeof window === "undefined" || typeof location === "undefined") {
@@ -752,20 +799,56 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
752
799
  return null;
753
800
  }
754
801
  const deadline = Date.now() + 180_000;
755
- while (Date.now() < deadline) {
756
- await new Promise((resolve) => {
757
- setTimeout(resolve, 1500);
758
- });
759
- const connection = await fetchCfConnection();
760
- if (connection) {
761
- popup.close();
762
- return connection;
763
- }
764
- if (popup.closed) {
765
- return fetchCfConnection();
766
- }
767
- }
768
- return null;
802
+ return new Promise<CfConnectionInfo>((resolve, reject) => {
803
+ let timer = 0;
804
+ const cleanup = () => {
805
+ window.removeEventListener("message", onMessage);
806
+ window.clearTimeout(timer);
807
+ if (!popup.closed) {
808
+ popup.close();
809
+ }
810
+ };
811
+ const settle = (connection: CfConnectionInfo) => {
812
+ cleanup();
813
+ resolve(connection);
814
+ };
815
+ const fail = (reason: string) => {
816
+ cleanup();
817
+ reject(new Error(reason));
818
+ };
819
+ const onMessage = (event: MessageEvent) => {
820
+ if (event.origin !== location.origin) {
821
+ return;
822
+ }
823
+ const data = event.data as { source?: string; status?: string; reason?: string | null };
824
+ if (!data || data.source !== "jx-cf") {
825
+ return;
826
+ }
827
+ if (data.status === "error") {
828
+ fail(data.reason ?? "Cloudflare authorization failed");
829
+ return;
830
+ }
831
+ void fetchCfConnection().then(settle);
832
+ };
833
+ window.addEventListener("message", onMessage);
834
+ const poll = async () => {
835
+ if (Date.now() > deadline) {
836
+ settle(null);
837
+ return;
838
+ }
839
+ const connection = await fetchCfConnection();
840
+ if (connection) {
841
+ settle(connection);
842
+ return;
843
+ }
844
+ if (popup.closed) {
845
+ settle(await fetchCfConnection());
846
+ return;
847
+ }
848
+ timer = window.setTimeout(() => void poll(), 1500);
849
+ };
850
+ timer = window.setTimeout(() => void poll(), 1500);
851
+ });
769
852
  },
770
853
 
771
854
  /** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
@@ -12,11 +12,23 @@ import { streamImport } from "../services/import-client";
12
12
  import type { WsCollabConnection } from "@jxsuite/collab/client";
13
13
  import type { ProjectConfig } from "@jxsuite/schema/types";
14
14
  import type {
15
+ DataConnectionsResponse,
16
+ DataConnectionTestResult,
17
+ DataPushResult,
18
+ DataRowDelete,
19
+ DataRowInsert,
20
+ DataRowsQuery,
21
+ DataRowsResult,
22
+ DataRowUpdate,
15
23
  DirEntry,
24
+ ExtensionsInfo,
16
25
  FsEvent,
17
26
  ImportProgressEvent,
18
27
  ImportSiteOptions,
28
+ ProjectSchemasResponse,
19
29
  RenameResult,
30
+ SecretsSetRequest,
31
+ SecretsSetResponse,
20
32
  StarterInfo,
21
33
  } from "../types";
22
34
 
@@ -613,6 +625,166 @@ export function createDevServerPlatform() {
613
625
  return body.formats ?? [];
614
626
  },
615
627
 
628
+ /** The extensions payload riding beside `formats` on the same route. */
629
+ async listExtensions(): Promise<ExtensionsInfo[]> {
630
+ const res = await fetch(`/__studio/formats?dir=${encodeURIComponent(serverPath("."))}`);
631
+ if (!res.ok) {
632
+ return [];
633
+ }
634
+ const body = await readJson<{ extensions?: ExtensionsInfo[] }>(res);
635
+ return body.extensions ?? [];
636
+ },
637
+
638
+ /** Pre-bundled per-project entry schemas for Monaco registration (empty when unavailable). */
639
+ async fetchProjectSchemas(): Promise<ProjectSchemasResponse> {
640
+ const res = await fetch(
641
+ `/__studio/project-schemas?dir=${encodeURIComponent(serverPath("."))}`,
642
+ );
643
+ if (!res.ok) {
644
+ return {};
645
+ }
646
+ return await readJson<ProjectSchemasResponse>(res);
647
+ },
648
+
649
+ // ─── Data surface + secrets (owner console over /__studio/data/* + /__studio/secrets) ──
650
+
651
+ /** Connector connections with configured state, table names, and provider metadata. */
652
+ async dataConnections(): Promise<DataConnectionsResponse> {
653
+ const res = await fetch(
654
+ `/__studio/data/connections?dir=${encodeURIComponent(serverPath("."))}`,
655
+ );
656
+ if (!res.ok) {
657
+ const data = await readJson<ErrorBody>(res);
658
+ throw new Error(data.error || "Failed to list connections");
659
+ }
660
+ return await readJson<DataConnectionsResponse>(res);
661
+ },
662
+
663
+ /** Probe one connection through the backend's connector registry. */
664
+ async dataConnectionTest(connection: string): Promise<DataConnectionTestResult> {
665
+ const res = await fetch(
666
+ `/__studio/data/connections/test?dir=${encodeURIComponent(serverPath("."))}`,
667
+ {
668
+ body: JSON.stringify({ connection }),
669
+ headers: { "Content-Type": "application/json" },
670
+ method: "POST",
671
+ },
672
+ );
673
+ const data = await readJson<DataConnectionTestResult & ErrorBody>(res);
674
+ if (!res.ok) {
675
+ throw new Error(data.error || "Connection test failed");
676
+ }
677
+ return data;
678
+ },
679
+
680
+ /** Additive schema push; `dryRun` compiles the plan without applying it. */
681
+ async dataPush(opts?: { connection?: string; dryRun?: boolean }): Promise<DataPushResult> {
682
+ const res = await fetch(`/__studio/data/push?dir=${encodeURIComponent(serverPath("."))}`, {
683
+ body: JSON.stringify(opts ?? {}),
684
+ headers: { "Content-Type": "application/json" },
685
+ method: "POST",
686
+ });
687
+ const data = await readJson<DataPushResult & ErrorBody>(res);
688
+ if (!res.ok) {
689
+ throw new Error(data.error || "Schema push failed");
690
+ }
691
+ return data;
692
+ },
693
+
694
+ /** Page a table's rows with introspected column metadata. */
695
+ async dataRows(query: DataRowsQuery): Promise<DataRowsResult> {
696
+ const params = new URLSearchParams({ dir: serverPath("."), table: query.table });
697
+ if (query.connection) {
698
+ params.set("connection", query.connection);
699
+ }
700
+ if (query.limit !== undefined) {
701
+ params.set("limit", String(query.limit));
702
+ }
703
+ if (query.offset !== undefined) {
704
+ params.set("offset", String(query.offset));
705
+ }
706
+ if (query.orderBy !== undefined) {
707
+ params.set("orderBy", query.orderBy);
708
+ }
709
+ if (query.dir !== undefined) {
710
+ params.set("dir", query.dir);
711
+ }
712
+ const res = await fetch(`/__studio/data/rows?${params}`);
713
+ const data = await readJson<DataRowsResult & ErrorBody>(res);
714
+ if (!res.ok) {
715
+ throw new Error(data.error || "Failed to load rows");
716
+ }
717
+ return data;
718
+ },
719
+
720
+ async dataInsertRow(req: DataRowInsert): Promise<{ row: Record<string, unknown> }> {
721
+ const res = await fetch(`/__studio/data/rows?dir=${encodeURIComponent(serverPath("."))}`, {
722
+ body: JSON.stringify(req),
723
+ headers: { "Content-Type": "application/json" },
724
+ method: "POST",
725
+ });
726
+ const data = await readJson<{ row: Record<string, unknown> } & ErrorBody>(res);
727
+ if (!res.ok) {
728
+ throw new Error(data.error || "Insert failed");
729
+ }
730
+ return data;
731
+ },
732
+
733
+ async dataUpdateRow(req: DataRowUpdate): Promise<{ row: Record<string, unknown> }> {
734
+ const res = await fetch(`/__studio/data/rows?dir=${encodeURIComponent(serverPath("."))}`, {
735
+ body: JSON.stringify(req),
736
+ headers: { "Content-Type": "application/json" },
737
+ method: "PUT",
738
+ });
739
+ const data = await readJson<{ row: Record<string, unknown> } & ErrorBody>(res);
740
+ if (!res.ok) {
741
+ throw new Error(data.error || "Update failed");
742
+ }
743
+ return data;
744
+ },
745
+
746
+ async dataDeleteRow(req: DataRowDelete): Promise<{ ok: boolean }> {
747
+ const params = new URLSearchParams({
748
+ dir: serverPath("."),
749
+ pk: String(req.pk),
750
+ table: req.table,
751
+ });
752
+ if (req.connection) {
753
+ params.set("connection", req.connection);
754
+ }
755
+ const res = await fetch(`/__studio/data/rows?${params}`, { method: "DELETE" });
756
+ const data = await readJson<{ ok: boolean } & ErrorBody>(res);
757
+ if (!res.ok) {
758
+ throw new Error(data.error || "Delete failed");
759
+ }
760
+ return data;
761
+ },
762
+
763
+ /** Configured secret env-var NAMES — never values. */
764
+ async listSecrets(): Promise<string[]> {
765
+ const res = await fetch(`/__studio/secrets?dir=${encodeURIComponent(serverPath("."))}`);
766
+ if (!res.ok) {
767
+ const data = await readJson<ErrorBody>(res);
768
+ throw new Error(data.error || "Failed to list secrets");
769
+ }
770
+ const data = await readJson<{ names: string[] }>(res);
771
+ return data.names;
772
+ },
773
+
774
+ /** Write/remove secrets in the dev server's .dev.vars; names-only response. */
775
+ async setSecrets(req: SecretsSetRequest): Promise<SecretsSetResponse> {
776
+ const res = await fetch(`/__studio/secrets?dir=${encodeURIComponent(serverPath("."))}`, {
777
+ body: JSON.stringify(req),
778
+ headers: { "Content-Type": "application/json" },
779
+ method: "PUT",
780
+ });
781
+ const data = await readJson<SecretsSetResponse & ErrorBody>(res);
782
+ if (!res.ok) {
783
+ throw new Error(data.error || "Failed to write secrets");
784
+ }
785
+ return data;
786
+ },
787
+
616
788
  /**
617
789
  * Invoke a format capability (parse/serialize) server-side.
618
790
  *