@rebasepro/studio 0.22.0 → 0.24.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 (62) hide show
  1. package/dist/{ApiExplorer-D-jLWAaY.js → ApiExplorer-CCtczj3F.js} +16 -32
  2. package/dist/ApiExplorer-CCtczj3F.js.map +1 -0
  3. package/dist/ApiKeysView-B66TdzhH.js +1738 -0
  4. package/dist/ApiKeysView-B66TdzhH.js.map +1 -0
  5. package/dist/BackupsView-DmSMzAbT.js +368 -0
  6. package/dist/BackupsView-DmSMzAbT.js.map +1 -0
  7. package/dist/{BranchesView-D6Sz1a5f.js → BranchesView-DP1a87E-.js} +12 -8
  8. package/dist/BranchesView-DP1a87E-.js.map +1 -0
  9. package/dist/{CronJobsView-JwRjQCde.js → CronJobsView-TDErHTuK.js} +143 -68
  10. package/dist/CronJobsView-TDErHTuK.js.map +1 -0
  11. package/dist/{JSEditor-qiuwmcvk.js → JSEditor-B59pwg4c.js} +120 -74
  12. package/dist/JSEditor-B59pwg4c.js.map +1 -0
  13. package/dist/{LogsExplorer-Xq8K4oo6.js → LogsExplorer-BKM4yfP8.js} +48 -22
  14. package/dist/LogsExplorer-BKM4yfP8.js.map +1 -0
  15. package/dist/{MonacoEditor-D0_QljcX.js → MonacoEditor-CUxy5_k5.js} +11 -9
  16. package/dist/MonacoEditor-CUxy5_k5.js.map +1 -0
  17. package/dist/{RLSEditor-B-qrgdHy.js → RLSEditor-BlLLV6xs.js} +350 -98
  18. package/dist/RLSEditor-BlLLV6xs.js.map +1 -0
  19. package/dist/{SQLEditor-sdtoZv5F.js → SQLEditor-1An7w0j6.js} +496 -224
  20. package/dist/SQLEditor-1An7w0j6.js.map +1 -0
  21. package/dist/{SchemaVisualizer-DtDzIUNy.js → SchemaVisualizer-DCLVfA5t.js} +20 -24
  22. package/dist/SchemaVisualizer-DCLVfA5t.js.map +1 -0
  23. package/dist/{StorageView-C52dSZNf.js → StorageView-CbsHuCBf.js} +382 -228
  24. package/dist/StorageView-CbsHuCBf.js.map +1 -0
  25. package/dist/components/ApiExplorer/TryItPanel.d.ts +6 -1
  26. package/dist/components/ApiKeys/CreateApiKeyDialog.d.ts +21 -2
  27. package/dist/components/ApiKeys/scope-words.d.ts +37 -0
  28. package/dist/components/ApiKeys/scopes.d.ts +113 -0
  29. package/dist/components/AuthSimulationSelector.d.ts +5 -1
  30. package/dist/components/LogsExplorer/useLogTail.d.ts +9 -0
  31. package/dist/components/RLSEditor/policyListing.d.ts +44 -0
  32. package/dist/components/RLSEditor/policyRules.d.ts +49 -0
  33. package/dist/components/SQLEditor/MonacoEditor.d.ts +13 -1
  34. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +0 -1
  35. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -0
  36. package/dist/components/load-failure.d.ts +1 -1
  37. package/dist/components/useRunAsUsers.d.ts +25 -0
  38. package/dist/index.es.js +11 -11
  39. package/dist/{load-failure-C6OMJSGC.js → load-failure-Bd8lFIO7.js} +2 -2
  40. package/dist/load-failure-Bd8lFIO7.js.map +1 -0
  41. package/dist/useRunAsUsers-CLaAtXt6.js +122 -0
  42. package/dist/useRunAsUsers-CLaAtXt6.js.map +1 -0
  43. package/dist/utils/sql_utils.d.ts +126 -36
  44. package/package.json +9 -9
  45. package/dist/ApiExplorer-D-jLWAaY.js.map +0 -1
  46. package/dist/ApiKeysView-OfJLH2x7.js +0 -1239
  47. package/dist/ApiKeysView-OfJLH2x7.js.map +0 -1
  48. package/dist/AuthSimulationSelector-BewmhMBw.js +0 -56
  49. package/dist/AuthSimulationSelector-BewmhMBw.js.map +0 -1
  50. package/dist/BackupsView-DYTAD6aW.js +0 -253
  51. package/dist/BackupsView-DYTAD6aW.js.map +0 -1
  52. package/dist/BranchesView-D6Sz1a5f.js.map +0 -1
  53. package/dist/CronJobsView-JwRjQCde.js.map +0 -1
  54. package/dist/JSEditor-qiuwmcvk.js.map +0 -1
  55. package/dist/LogsExplorer-Xq8K4oo6.js.map +0 -1
  56. package/dist/MonacoEditor-D0_QljcX.js.map +0 -1
  57. package/dist/RLSEditor-B-qrgdHy.js.map +0 -1
  58. package/dist/SQLEditor-sdtoZv5F.js.map +0 -1
  59. package/dist/SchemaVisualizer-DtDzIUNy.js.map +0 -1
  60. package/dist/StorageView-C52dSZNf.js.map +0 -1
  61. package/dist/components/ApiKeys/permissions.d.ts +0 -66
  62. package/dist/load-failure-C6OMJSGC.js.map +0 -1
@@ -0,0 +1,49 @@
1
+ import type { PolicyExpression, PostgresPolicy, SecurityRule, SecurityRuleBase } from "@rebasepro/types";
2
+ /**
3
+ * The security-rule fields a {@link PostgresPolicy} describes, and the ones an
4
+ * edit carries over from the rule it changes.
5
+ *
6
+ * Wider than any one `SecurityRule` variant on purpose: an edit starts from the
7
+ * rule as written and deletes and assigns the fields that changed one at a
8
+ * time, and the variants are exclusive about which fields go together.
9
+ */
10
+ export type PolicyRule = SecurityRuleBase & {
11
+ using?: string;
12
+ withCheck?: string;
13
+ ownerField?: string;
14
+ access?: "public";
15
+ condition?: PolicyExpression;
16
+ check?: PolicyExpression;
17
+ };
18
+ /** Why an edited policy could not be written back to a rule. */
19
+ export type PolicyEditRefusal =
20
+ /** Rebase adds it (the admin baseline, tenancy); no declared rule holds it. */
21
+ "generated"
22
+ /** It exists only in the database. */
23
+ | "not_declared"
24
+ /** Its rule covers several operations, and the policy is one of them. */
25
+ | "several_operations";
26
+ export type PolicyEdit = {
27
+ ok: true;
28
+ rules: PolicyRule[];
29
+ } | {
30
+ ok: false;
31
+ reason: PolicyEditRefusal;
32
+ };
33
+ /**
34
+ * Write an edit of one policy back to the declared rule it compiles from.
35
+ *
36
+ * The rule is found by the policy names it compiles to, not by `rule.name`:
37
+ * an unnamed rule's policy is `<table>_<op>_<hash>`, and a rule over several
38
+ * operations names each `<name>_<op>`. Matching on `name` missed both, saved
39
+ * the rules unchanged and reported success.
40
+ *
41
+ * A rule over several operations is refused rather than split: taking one
42
+ * operation out renames every policy the rule compiles to.
43
+ *
44
+ * @param declared The collection's `securityRules`, as written.
45
+ * @param effective Every rule the generator compiles for the table — the
46
+ * declared ones plus what Rebase adds — to tell a generated
47
+ * policy from one that exists only in the database.
48
+ */
49
+ export declare function applyPolicyEdit(declared: readonly SecurityRule[], effective: readonly SecurityRule[], tableName: string, original: PostgresPolicy, edited: Partial<PostgresPolicy>): PolicyEdit;
@@ -1,5 +1,17 @@
1
1
  import React from "react";
2
+ /**
3
+ * What a host can ask of the editor outside its own key bindings.
4
+ *
5
+ * The toolbar's buttons need the selection that Cmd+Enter already reads: a
6
+ * button that runs the whole buffer while the shortcut beside it runs only the
7
+ * highlighted statement is two answers to one question.
8
+ */
9
+ export interface MonacoEditorHandle {
10
+ /** The selected text, trimmed — `undefined` when nothing is selected. */
11
+ getSelectedText: () => string | undefined;
12
+ }
2
13
  export type MonacoEditorProps = {
14
+ ref?: React.Ref<MonacoEditorHandle>;
3
15
  value: string;
4
16
  onChange: (value: string | undefined) => void;
5
17
  onRun?: (selectedText?: string) => void;
@@ -15,4 +27,4 @@ export type MonacoEditorProps = {
15
27
  }[];
16
28
  }[]>;
17
29
  };
18
- export declare const MonacoEditor: ({ value, onChange, onRun, className, readOnly, autoFocus, schemas }: MonacoEditorProps) => React.JSX.Element;
30
+ export declare const MonacoEditor: ({ ref, value, onChange, onRun, className, readOnly, autoFocus, schemas }: MonacoEditorProps) => React.JSX.Element;
@@ -25,7 +25,6 @@ export declare const getLayoutedElements: (nodes: Node[], edges: Edge[]) => {
25
25
  nodes: Node[];
26
26
  edges: Edge[];
27
27
  };
28
- export declare const getTypeLabel: (type: string) => string;
29
28
  export interface RelationEdgeData {
30
29
  cardinality: "one" | "many";
31
30
  direction: "owning" | "inverse";
@@ -3,6 +3,12 @@ import type { AdminCollection } from "@rebasepro/cms-types";
3
3
  export interface ColumnInfo {
4
4
  name: string;
5
5
  type: string;
6
+ /**
7
+ * The type the collection declares — `string`, `number`, `date`… — or
8
+ * `FK` for a relation's key. Not the database's column type: the diagram
9
+ * is drawn from the collections, and a property's column type depends on
10
+ * more than its `type` (`columnType`, `isId`, integer validation).
11
+ */
6
12
  typeLabel: string;
7
13
  isPrimaryKey: boolean;
8
14
  isForeignKey: boolean;
@@ -35,7 +35,7 @@
35
35
  export type LoadFailureKind =
36
36
  /** The project's own rules, or the caller's own role, said no. Not an error. */
37
37
  "denied"
38
- /** We could not reach or read the source. */
38
+ /** We could not reach or read the source — including a session the server no longer accepts. */
39
39
  | "unavailable";
40
40
  export interface LoadFailure {
41
41
  kind: LoadFailureKind;
@@ -0,0 +1,25 @@
1
+ import { type SelectableUser } from "@rebasepro/app";
2
+ export interface RunAsUsers {
3
+ /** The signed-in user first, then the users the latest search found. */
4
+ users: SelectableUser[];
5
+ /** The signed-in user, the picker's "self" row. */
6
+ currentUser: SelectableUser | null;
7
+ loading: boolean;
8
+ /** Search the project's users on the server. */
9
+ onSearchTextChange: (searchText: string) => void;
10
+ /**
11
+ * How many users the latest search matched on the server, set only when
12
+ * that is more than the one page listed — so the picker can say so rather
13
+ * than present the page as everyone.
14
+ */
15
+ totalCount?: number;
16
+ }
17
+ /**
18
+ * The users a "Run as" picker offers.
19
+ *
20
+ * The signed-in user, and — for an administrator, the only caller the server
21
+ * lets run a request as someone else — the project's users, searched on the
22
+ * server. Disabled accounts are left out: nothing can run as them, and the
23
+ * server refuses them.
24
+ */
25
+ export declare function useRunAsUsers(): RunAsUsers;
package/dist/index.es.js CHANGED
@@ -550,17 +550,17 @@ function SyntaxHighlightedSnippet() {
550
550
  }
551
551
  //#endregion
552
552
  //#region src/components/RebaseStudio.tsx
553
- var SQLEditor = lazyChunk(() => import("./SQLEditor-sdtoZv5F.js").then((m) => ({ default: m.SQLEditor })));
554
- var JSEditor = lazyChunk(() => import("./JSEditor-qiuwmcvk.js").then((m) => ({ default: m.JSEditor })));
555
- var RLSEditor = lazyChunk(() => import("./RLSEditor-B-qrgdHy.js").then((m) => ({ default: m.RLSEditor })));
556
- var StorageView = lazyChunk(() => import("./StorageView-C52dSZNf.js").then((m) => ({ default: m.StorageView })));
557
- var CronJobsView = lazyChunk(() => import("./CronJobsView-JwRjQCde.js").then((m) => ({ default: m.CronJobsView })));
558
- var SchemaVisualizer = lazyChunk(() => import("./SchemaVisualizer-DtDzIUNy.js").then((m) => ({ default: m.SchemaVisualizer })));
559
- var BranchesView = lazyChunk(() => import("./BranchesView-D6Sz1a5f.js").then((m) => ({ default: m.BranchesView })));
560
- var BackupsView = lazyChunk(() => import("./BackupsView-DYTAD6aW.js").then((m) => ({ default: m.BackupsView })));
561
- var ApiExplorer = lazyChunk(() => import("./ApiExplorer-D-jLWAaY.js").then((m) => ({ default: m.ApiExplorer })));
562
- var LogsExplorer = lazyChunk(() => import("./LogsExplorer-Xq8K4oo6.js").then((m) => ({ default: m.LogsExplorer })));
563
- var ApiKeysView = lazyChunk(() => import("./ApiKeysView-OfJLH2x7.js").then((m) => ({ default: m.ApiKeysView })));
553
+ var SQLEditor = lazyChunk(() => import("./SQLEditor-1An7w0j6.js").then((m) => ({ default: m.SQLEditor })));
554
+ var JSEditor = lazyChunk(() => import("./JSEditor-B59pwg4c.js").then((m) => ({ default: m.JSEditor })));
555
+ var RLSEditor = lazyChunk(() => import("./RLSEditor-BlLLV6xs.js").then((m) => ({ default: m.RLSEditor })));
556
+ var StorageView = lazyChunk(() => import("./StorageView-CbsHuCBf.js").then((m) => ({ default: m.StorageView })));
557
+ var CronJobsView = lazyChunk(() => import("./CronJobsView-TDErHTuK.js").then((m) => ({ default: m.CronJobsView })));
558
+ var SchemaVisualizer = lazyChunk(() => import("./SchemaVisualizer-DCLVfA5t.js").then((m) => ({ default: m.SchemaVisualizer })));
559
+ var BranchesView = lazyChunk(() => import("./BranchesView-DP1a87E-.js").then((m) => ({ default: m.BranchesView })));
560
+ var BackupsView = lazyChunk(() => import("./BackupsView-DmSMzAbT.js").then((m) => ({ default: m.BackupsView })));
561
+ var ApiExplorer = lazyChunk(() => import("./ApiExplorer-CCtczj3F.js").then((m) => ({ default: m.ApiExplorer })));
562
+ var LogsExplorer = lazyChunk(() => import("./LogsExplorer-BKM4yfP8.js").then((m) => ({ default: m.LogsExplorer })));
563
+ var ApiKeysView = lazyChunk(() => import("./ApiKeysView-B66TdzhH.js").then((m) => ({ default: m.ApiKeysView })));
564
564
  /**
565
565
  * Declarative component to configure the Studio in Rebase.
566
566
  * Renders nothing — purely registers config into the RebaseRegistry.
@@ -15,7 +15,7 @@ function statusOf(err) {
15
15
  function classifyLoadFailure(err) {
16
16
  const detail = err instanceof Error ? err.message : String(err);
17
17
  const status = statusOf(err);
18
- return status === 403 || status === 401 || /\bnot authori[sz]ed\b|\bforbidden\b|\bpermission denied\b/i.test(detail) ? {
18
+ return (status !== null ? status === 403 : /\bnot authori[sz]ed\b|\bforbidden\b|\bpermission denied\b/i.test(detail)) ? {
19
19
  kind: "denied",
20
20
  detail,
21
21
  retryable: false
@@ -28,4 +28,4 @@ function classifyLoadFailure(err) {
28
28
  //#endregion
29
29
  export { classifyLoadFailure as t };
30
30
 
31
- //# sourceMappingURL=load-failure-C6OMJSGC.js.map
31
+ //# sourceMappingURL=load-failure-Bd8lFIO7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-failure-Bd8lFIO7.js","names":[],"sources":["../src/components/load-failure.ts"],"sourcesContent":["/**\n * Why a Studio listing failed — and whether it is the platform's fault.\n *\n * ## Why this is a function\n *\n * The storage pane rendered one view for every failure: a red card titled\n * **\"Error loading storage\"** with whatever string came back underneath.\n *\n * On 2026-08-27 a customer opened the Files tab of a healthy project and read\n * \"Error loading storage — Not authorized for this object\". Nothing was broken.\n * That project's own `storageAuthorize` hook refuses an unscoped listing on\n * purpose — its comment says a `list` that is not scoped to a team \"is precisely\n * the enumeration this exists to stop\" — and the Files tab always opens at the\n * bucket root. The console took a policy working exactly as written and\n * presented it as a fault in the platform, which sends the reader hunting for a\n * problem that does not exist.\n *\n * A refusal and a failure need different words, a different colour, and a\n * different next step: one is \"your rule said no\", the other is \"we could not\n * ask\". Retry is an affordance for the second and noise on the first.\n *\n * ## Why it is shared\n *\n * Storage was the only pane that had learned this. Backups, Cron Jobs, API Keys\n * and Branches each caught the error, opened a snackbar that is gone in four\n * seconds, and left their list empty — so a reader who looked away, or arrived\n * after the toast, was told \"No backups found yet\" about a database that has\n * backups and an account that may not list them. One classifier and one view, so\n * the next pane inherits the distinction instead of rediscovering it.\n *\n * The view that renders one of these is `load-failure-view.tsx`. It is a\n * separate file so that this classifier — the part with the rules in it — can\n * be tested without dragging in `@rebasepro/app` and, behind it, react-router.\n */\n\nexport type LoadFailureKind =\n /** The project's own rules, or the caller's own role, said no. Not an error. */\n | \"denied\"\n /** We could not reach or read the source — including a session the server no longer accepts. */\n | \"unavailable\";\n\nexport interface LoadFailure {\n kind: LoadFailureKind;\n /** The message as the server gave it, never re-worded. */\n detail: string;\n /** Offering retry against a standing policy teaches people the button does nothing. */\n retryable: boolean;\n}\n\n/** HTTP status carried by whatever the SDK threw, if it carries one. */\nfunction statusOf(err: unknown): number | null {\n if (typeof err !== \"object\" || err === null) return null;\n for (const key of [\"status\", \"statusCode\", \"code\"]) {\n const v = (err as Record<string, unknown>)[key];\n if (typeof v === \"number\" && v >= 100 && v < 600) return v;\n }\n return null;\n}\n\nexport function classifyLoadFailure(err: unknown): LoadFailure {\n const detail = err instanceof Error ? err.message : String(err);\n const status = statusOf(err);\n\n // 403 is the rule's answer, and the only status that is. A 401 is the\n // session — expired, revoked, signed out elsewhere — and says nothing about\n // what the caller's role may do; a 5xx is the platform, even when its\n // message says \"permission denied\" (a missing GRANT is answered that way).\n // Both are failures to read, with Retry.\n //\n // The message match is only for an error that carries no status at all: an\n // SDK that flattens an error to a string is how a refusal reached the\n // screen unclassified.\n const denied = status !== null\n ? status === 403\n : /\\bnot authori[sz]ed\\b|\\bforbidden\\b|\\bpermission denied\\b/i.test(detail);\n\n return denied\n ? { kind: \"denied\", detail, retryable: false }\n : { kind: \"unavailable\", detail, retryable: true };\n}\n"],"mappings":";;AAkDA,SAAS,SAAS,KAA6B;CAC3C,IAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM,OAAO;CACpD,KAAK,MAAM,OAAO;EAAC;EAAU;EAAc;CAAM,GAAG;EAChD,MAAM,IAAK,IAAgC;EAC3C,IAAI,OAAO,MAAM,YAAY,KAAK,OAAO,IAAI,KAAK,OAAO;CAC7D;CACA,OAAO;AACX;AAEA,SAAgB,oBAAoB,KAA2B;CAC3D,MAAM,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;CAC9D,MAAM,SAAS,SAAS,GAAG;CAe3B,QAJe,WAAW,OACpB,WAAW,MACX,6DAA6D,KAAK,MAAM,KAGxE;EAAE,MAAM;EAAU;EAAQ,WAAW;CAAM,IAC3C;EAAE,MAAM;EAAe;EAAQ,WAAW;CAAK;AACzD"}
@@ -0,0 +1,122 @@
1
+ import { UserSelectPopover, useRebaseClient, useRebaseContext } from "@rebasepro/app";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Button, KeyRoundIcon, Typography, iconSize } from "@rebasepro/ui";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ import { hasAdminRole } from "@rebasepro/types";
6
+ //#region src/components/AuthSimulationSelector.tsx
7
+ function AuthSimulationSelector({ authMode, setAuthMode, selectedUser, setSelectedUser, users, loading, onUserSearchTextChange, userTotalCount, currentUser }) {
8
+ return /* @__PURE__ */ jsxs("div", {
9
+ className: "flex flex-wrap items-center gap-3",
10
+ children: [
11
+ /* @__PURE__ */ jsx(KeyRoundIcon, {
12
+ size: iconSize.small,
13
+ className: "text-text-secondary dark:text-text-secondary-dark"
14
+ }),
15
+ /* @__PURE__ */ jsx(Typography, {
16
+ variant: "caption",
17
+ className: "text-text-secondary dark:text-text-secondary-dark text-xs font-medium",
18
+ children: "Auth:"
19
+ }),
20
+ /* @__PURE__ */ jsx(Button, {
21
+ size: "small",
22
+ variant: authMode === "jwt" ? "outlined" : "outlined",
23
+ color: authMode === "jwt" ? "primary" : "neutral",
24
+ onClick: () => setAuthMode("jwt"),
25
+ className: "rounded-full !px-3 !py-1 !text-xs",
26
+ children: "JWT Token"
27
+ }),
28
+ /* @__PURE__ */ jsx(Button, {
29
+ size: "small",
30
+ variant: authMode === "none" ? "outlined" : "outlined",
31
+ color: authMode === "none" ? "error" : "neutral",
32
+ onClick: () => setAuthMode("none"),
33
+ className: "rounded-full !px-3 !py-1 !text-xs",
34
+ children: "No Auth"
35
+ }),
36
+ authMode === "jwt" && /* @__PURE__ */ jsxs(Fragment, { children: [
37
+ /* @__PURE__ */ jsx("div", { className: "w-px h-4 bg-hairline-strong mx-1" }),
38
+ /* @__PURE__ */ jsx(Typography, {
39
+ variant: "caption",
40
+ className: "text-text-secondary dark:text-text-secondary-dark text-xs font-medium",
41
+ children: "Run as:"
42
+ }),
43
+ /* @__PURE__ */ jsx(UserSelectPopover, {
44
+ selectedUser,
45
+ onUserSelected: setSelectedUser,
46
+ users,
47
+ loading,
48
+ onSearchTextChange: onUserSearchTextChange,
49
+ totalCount: userTotalCount,
50
+ currentUser
51
+ })
52
+ ] })
53
+ ]
54
+ });
55
+ }
56
+ //#endregion
57
+ //#region src/components/useRunAsUsers.ts
58
+ /** How many users one search of a "Run as" picker lists. */
59
+ var USER_PAGE_SIZE = 50;
60
+ /** The fields a picker row shows, copied off an account or the signed-in user. */
61
+ function toSelectableUser(user) {
62
+ return {
63
+ uid: user.uid,
64
+ displayName: user.displayName,
65
+ email: user.email,
66
+ photoURL: user.photoURL,
67
+ roles: user.roles
68
+ };
69
+ }
70
+ /**
71
+ * The users a "Run as" picker offers.
72
+ *
73
+ * The signed-in user, and — for an administrator, the only caller the server
74
+ * lets run a request as someone else — the project's users, searched on the
75
+ * server. Disabled accounts are left out: nothing can run as them, and the
76
+ * server refuses them.
77
+ */
78
+ function useRunAsUsers() {
79
+ const signedIn = useRebaseContext().authController?.user;
80
+ const client = useRebaseClient();
81
+ const adminApi = hasAdminRole(signedIn?.roles) ? client?.admin : void 0;
82
+ const [search, setSearch] = useState("");
83
+ const [listed, setListed] = useState([]);
84
+ const [totalCount, setTotalCount] = useState(void 0);
85
+ const [loading, setLoading] = useState(false);
86
+ useEffect(() => {
87
+ if (!adminApi) return;
88
+ let cancelled = false;
89
+ setLoading(true);
90
+ adminApi.listUsersPaginated({
91
+ search: search || void 0,
92
+ limit: USER_PAGE_SIZE
93
+ }).then(({ users, total }) => {
94
+ if (cancelled) return;
95
+ setListed(users.filter((u) => !u.disabled).map(toSelectableUser));
96
+ setTotalCount(total > users.length ? total : void 0);
97
+ }).catch((err) => {
98
+ console.warn("Could not list users for the \"Run as\" picker", err);
99
+ if (!cancelled) {
100
+ setListed([]);
101
+ setTotalCount(void 0);
102
+ }
103
+ }).finally(() => {
104
+ if (!cancelled) setLoading(false);
105
+ });
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [adminApi, search]);
110
+ const currentUser = useMemo(() => signedIn ? toSelectableUser(signedIn) : null, [signedIn]);
111
+ return {
112
+ users: useMemo(() => [...currentUser ? [currentUser] : [], ...listed.filter((u) => u.uid !== currentUser?.uid)], [currentUser, listed]),
113
+ currentUser,
114
+ loading,
115
+ onSearchTextChange: setSearch,
116
+ totalCount
117
+ };
118
+ }
119
+ //#endregion
120
+ export { AuthSimulationSelector as n, useRunAsUsers as t };
121
+
122
+ //# sourceMappingURL=useRunAsUsers-CLaAtXt6.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRunAsUsers-CLaAtXt6.js","names":[],"sources":["../src/components/AuthSimulationSelector.tsx","../src/components/useRunAsUsers.ts"],"sourcesContent":["import React from \"react\";\nimport { Button, cls, iconSize, KeyRoundIcon, Typography } from \"@rebasepro/ui\";\nimport { UserSelectPopover, SelectableUser } from \"@rebasepro/app\";\n\nexport interface AuthSimulationSelectorProps {\n authMode: \"jwt\" | \"none\";\n setAuthMode: (mode: \"jwt\" | \"none\") => void;\n selectedUser: SelectableUser | null;\n setSelectedUser: (user: SelectableUser | null) => void;\n users: SelectableUser[];\n loading?: boolean;\n /** For a caller that searches `users` on the server — see `UserSelectPopover`. */\n onUserSearchTextChange?: (searchText: string) => void;\n /** How many users that search matched, when `users` is only its first page — see `UserSelectPopover`. */\n userTotalCount?: number;\n currentUser: SelectableUser | null;\n}\n\nexport function AuthSimulationSelector({\n authMode,\n setAuthMode,\n selectedUser,\n setSelectedUser,\n users,\n loading,\n onUserSearchTextChange,\n userTotalCount,\n currentUser\n}: AuthSimulationSelectorProps) {\n return (\n <div className=\"flex flex-wrap items-center gap-3\">\n <KeyRoundIcon size={iconSize.small} className=\"text-text-secondary dark:text-text-secondary-dark\" />\n <Typography\n variant=\"caption\"\n className=\"text-text-secondary dark:text-text-secondary-dark text-xs font-medium\"\n >\n Auth:\n </Typography>\n <Button\n size=\"small\"\n variant={authMode === \"jwt\" ? \"outlined\" : \"outlined\"}\n color={authMode === \"jwt\" ? \"primary\" : \"neutral\"}\n onClick={() => setAuthMode(\"jwt\")}\n className=\"rounded-full !px-3 !py-1 !text-xs\"\n >\n JWT Token\n </Button>\n <Button\n size=\"small\"\n variant={authMode === \"none\" ? \"outlined\" : \"outlined\"}\n color={authMode === \"none\" ? \"error\" : \"neutral\"}\n onClick={() => setAuthMode(\"none\")}\n className=\"rounded-full !px-3 !py-1 !text-xs\"\n >\n No Auth\n </Button>\n\n {authMode === \"jwt\" && (\n <>\n <div className=\"w-px h-4 bg-hairline-strong mx-1\" />\n <Typography\n variant=\"caption\"\n className=\"text-text-secondary dark:text-text-secondary-dark text-xs font-medium\"\n >\n Run as:\n </Typography>\n <UserSelectPopover\n selectedUser={selectedUser}\n onUserSelected={setSelectedUser}\n users={users}\n loading={loading}\n onSearchTextChange={onUserSearchTextChange}\n totalCount={userTotalCount}\n currentUser={currentUser}\n />\n </>\n )}\n </div>\n );\n}\n","import { useEffect, useMemo, useState } from \"react\";\nimport { useRebaseClient, useRebaseContext, type SelectableUser } from \"@rebasepro/app\";\nimport { hasAdminRole, type RebaseClient } from \"@rebasepro/types\";\n\n/** How many users one search of a \"Run as\" picker lists. */\nconst USER_PAGE_SIZE = 50;\n\n/** The fields a picker row shows, copied off an account or the signed-in user. */\nfunction toSelectableUser(user: SelectableUser): SelectableUser {\n return {\n uid: user.uid,\n displayName: user.displayName,\n email: user.email,\n photoURL: user.photoURL,\n roles: user.roles\n };\n}\n\nexport interface RunAsUsers {\n /** The signed-in user first, then the users the latest search found. */\n users: SelectableUser[];\n /** The signed-in user, the picker's \"self\" row. */\n currentUser: SelectableUser | null;\n loading: boolean;\n /** Search the project's users on the server. */\n onSearchTextChange: (searchText: string) => void;\n /**\n * How many users the latest search matched on the server, set only when\n * that is more than the one page listed — so the picker can say so rather\n * than present the page as everyone.\n */\n totalCount?: number;\n}\n\n/**\n * The users a \"Run as\" picker offers.\n *\n * The signed-in user, and — for an administrator, the only caller the server\n * lets run a request as someone else — the project's users, searched on the\n * server. Disabled accounts are left out: nothing can run as them, and the\n * server refuses them.\n */\nexport function useRunAsUsers(): RunAsUsers {\n const signedIn = useRebaseContext().authController?.user;\n const client = useRebaseClient<RebaseClient>();\n const adminApi = hasAdminRole(signedIn?.roles) ? client?.admin : undefined;\n\n const [search, setSearch] = useState(\"\");\n const [listed, setListed] = useState<SelectableUser[]>([]);\n const [totalCount, setTotalCount] = useState<number | undefined>(undefined);\n const [loading, setLoading] = useState(false);\n\n useEffect(() => {\n if (!adminApi) return;\n let cancelled = false;\n setLoading(true);\n adminApi.listUsersPaginated({ search: search || undefined, limit: USER_PAGE_SIZE })\n .then(({ users, total }) => {\n if (cancelled) return;\n setListed(users.filter((u) => !u.disabled).map(toSelectableUser));\n setTotalCount(total > users.length ? total : undefined);\n })\n .catch((err: unknown) => {\n console.warn(\"Could not list users for the \\\"Run as\\\" picker\", err);\n if (!cancelled) {\n setListed([]);\n setTotalCount(undefined);\n }\n })\n .finally(() => {\n if (!cancelled) setLoading(false);\n });\n return () => {\n cancelled = true;\n };\n }, [adminApi, search]);\n\n const currentUser = useMemo((): SelectableUser | null => (signedIn ? toSelectableUser(signedIn) : null), [signedIn]);\n\n const users = useMemo(\n () => [...(currentUser ? [currentUser] : []), ...listed.filter((u) => u.uid !== currentUser?.uid)],\n [currentUser, listed]\n );\n\n return { users, currentUser, loading, onSearchTextChange: setSearch, totalCount };\n}\n"],"mappings":";;;;;;AAkBA,SAAgB,uBAAuB,EACnC,UACA,aACA,cACA,iBACA,OACA,SACA,wBACA,gBACA,eAC4B;CAC5B,OACI,qBAAC,OAAD;EAAK,WAAU;YAAf;GACI,oBAAC,cAAD;IAAc,MAAM,SAAS;IAAO,WAAU;GAAqD,CAAA;GACnG,oBAAC,YAAD;IACI,SAAQ;IACR,WAAU;cACb;GAEW,CAAA;GACZ,oBAAC,QAAD;IACI,MAAK;IACL,SAAS,aAAa,QAAQ,aAAa;IAC3C,OAAO,aAAa,QAAQ,YAAY;IACxC,eAAe,YAAY,KAAK;IAChC,WAAU;cACb;GAEO,CAAA;GACR,oBAAC,QAAD;IACI,MAAK;IACL,SAAS,aAAa,SAAS,aAAa;IAC5C,OAAO,aAAa,SAAS,UAAU;IACvC,eAAe,YAAY,MAAM;IACjC,WAAU;cACb;GAEO,CAAA;GAEP,aAAa,SACV,qBAAA,UAAA,EAAA,UAAA;IACI,oBAAC,OAAD,EAAK,WAAU,mCAAoC,CAAA;IACnD,oBAAC,YAAD;KACI,SAAQ;KACR,WAAU;eACb;IAEW,CAAA;IACZ,oBAAC,mBAAD;KACkB;KACd,gBAAgB;KACT;KACE;KACT,oBAAoB;KACpB,YAAY;KACC;IAChB,CAAA;GACH,EAAA,CAAA;EAEL;;AAEb;;;;AC1EA,IAAM,iBAAiB;;AAGvB,SAAS,iBAAiB,MAAsC;CAC5D,OAAO;EACH,KAAK,KAAK;EACV,aAAa,KAAK;EAClB,OAAO,KAAK;EACZ,UAAU,KAAK;EACf,OAAO,KAAK;CAChB;AACJ;;;;;;;;;AA0BA,SAAgB,gBAA4B;CACxC,MAAM,WAAW,iBAAiB,CAAC,CAAC,gBAAgB;CACpD,MAAM,SAAS,gBAA8B;CAC7C,MAAM,WAAW,aAAa,UAAU,KAAK,IAAI,QAAQ,QAAQ,KAAA;CAEjE,MAAM,CAAC,QAAQ,aAAa,SAAS,EAAE;CACvC,MAAM,CAAC,QAAQ,aAAa,SAA2B,CAAC,CAAC;CACzD,MAAM,CAAC,YAAY,iBAAiB,SAA6B,KAAA,CAAS;CAC1E,MAAM,CAAC,SAAS,cAAc,SAAS,KAAK;CAE5C,gBAAgB;EACZ,IAAI,CAAC,UAAU;EACf,IAAI,YAAY;EAChB,WAAW,IAAI;EACf,SAAS,mBAAmB;GAAE,QAAQ,UAAU,KAAA;GAAW,OAAO;EAAe,CAAC,CAAC,CAC9E,MAAM,EAAE,OAAO,YAAY;GACxB,IAAI,WAAW;GACf,UAAU,MAAM,QAAQ,MAAM,CAAC,EAAE,QAAQ,CAAC,CAAC,IAAI,gBAAgB,CAAC;GAChE,cAAc,QAAQ,MAAM,SAAS,QAAQ,KAAA,CAAS;EAC1D,CAAC,CAAC,CACD,OAAO,QAAiB;GACrB,QAAQ,KAAK,kDAAkD,GAAG;GAClE,IAAI,CAAC,WAAW;IACZ,UAAU,CAAC,CAAC;IACZ,cAAc,KAAA,CAAS;GAC3B;EACJ,CAAC,CAAC,CACD,cAAc;GACX,IAAI,CAAC,WAAW,WAAW,KAAK;EACpC,CAAC;EACL,aAAa;GACT,YAAY;EAChB;CACJ,GAAG,CAAC,UAAU,MAAM,CAAC;CAErB,MAAM,cAAc,cAAsC,WAAW,iBAAiB,QAAQ,IAAI,MAAO,CAAC,QAAQ,CAAC;CAOnH,OAAO;EAAE,OALK,cACJ,CAAC,GAAI,cAAc,CAAC,WAAW,IAAI,CAAC,GAAI,GAAG,OAAO,QAAQ,MAAM,EAAE,QAAQ,aAAa,GAAG,CAAC,GACjG,CAAC,aAAa,MAAM,CAGf;EAAO;EAAa;EAAS,oBAAoB;EAAW;CAAW;AACpF"}
@@ -1,52 +1,142 @@
1
- import type { TableInfo } from "../components/SQLEditor/sql_editor_types.js";
1
+ import type { AdminCollection } from "@rebasepro/cms-types";
2
+ import type { SqlScriptResult } from "@rebasepro/types";
3
+ /**
4
+ * The `EXPLAIN` for one statement, or `null` when `sqlText` is not exactly one.
5
+ *
6
+ * Never `ANALYZE`: that executes the statement to time it, so explaining a
7
+ * `DELETE` deleted the rows — without the confirmation "Run" asks for.
8
+ *
9
+ * And never more than one statement. Sent as one simple query, `EXPLAIN (…)
10
+ * SELECT 1; DELETE …` explains the SELECT and *runs* the DELETE. Text the
11
+ * parser cannot read is still explained, as long as no `;` could be hiding a
12
+ * second statement in it.
13
+ */
14
+ export declare function buildExplainSql(sqlText: string): string | null;
15
+ /**
16
+ * Whether the console's automatic `LIMIT` may be appended to `sqlText`: one
17
+ * top-level SELECT that has no limit of its own.
18
+ *
19
+ * Decided on the parsed statement, not the text. A search for the word SELECT
20
+ * also found it inside `INSERT INTO … SELECT` and `CREATE TABLE … AS SELECT`,
21
+ * which then copied a thousand rows and reported success, and at the start of
22
+ * a script whose last statement the appended `LIMIT` turned into a syntax
23
+ * error. Text the parser cannot read is left as written.
24
+ */
25
+ export declare function acceptsAutoLimit(sqlText: string): boolean;
26
+ /**
27
+ * Whether "Run" asks before running `sqlText`: a statement in it deletes or
28
+ * updates without a WHERE of its own, drops or truncates.
29
+ *
30
+ * Decided per parsed statement, not on the text. A search of the text for the
31
+ * word WHERE found it in another statement or in a comment, so `DELETE FROM
32
+ * posts; SELECT * FROM posts WHERE id = 1` deleted every post without asking.
33
+ * Text the parser cannot read is confirmed whenever it names a destructive
34
+ * command.
35
+ */
36
+ export declare function needsDestructiveConfirmation(sqlText: string): boolean;
2
37
  /**
3
- * A table extracted from a SQL query's FROM/JOIN clauses.
38
+ * A table as SQL: `"schema"."table"` when a schema is given, a bare `"table"`
39
+ * otherwise.
4
40
  */
5
- export interface ExtractedTable {
6
- name: string;
7
- alias?: string;
41
+ export declare function quoteTableName(tableName: string, schemaName?: string): string;
42
+ /** An identifier as SQL, whatever it holds: double-quoted, with `"` doubled. */
43
+ export declare function quoteIdentifier(identifier: string): string;
44
+ /** What the console knows about a result: its columns, and the tables they came from. */
45
+ export type ResultProvenance = Pick<SqlScriptResult, "columns" | "tables">;
46
+ /**
47
+ * The stored cell a result cell is, and the key that finds its row.
48
+ */
49
+ export interface CellEditTarget {
50
+ schema: string;
51
+ table: string;
52
+ /** The table column the edited value is written to. */
53
+ column: string;
54
+ /** Each primary key column of the table, and the result column holding its value for the row. */
55
+ key: {
56
+ column: string;
57
+ resultColumn: string;
58
+ }[];
8
59
  }
9
60
  /**
10
- * A collection matched to a table in a SQL query.
61
+ * Why a cell cannot be edited: a translation key of the console's, and its
62
+ * parameters.
63
+ */
64
+ export interface CellEditRefusal {
65
+ key: "studio_sql_edit_unreadable_query" | "studio_sql_edit_not_a_select" | "studio_sql_edit_duplicate_column" | "studio_sql_edit_computed_column" | "studio_sql_edit_not_a_table" | "studio_sql_edit_inherited_table" | "studio_sql_edit_no_primary_key" | "studio_sql_edit_key_missing" | "studio_sql_edit_table_read_twice";
66
+ params: Record<string, string>;
67
+ }
68
+ export type CellEditResolution = {
69
+ target: CellEditTarget;
70
+ refusal?: undefined;
71
+ } | {
72
+ target?: undefined;
73
+ refusal: CellEditRefusal;
74
+ };
75
+ /**
76
+ * Which stored cell an edit of the result column `resultColumn` writes, and
77
+ * how its row is found again — or why no edit is safe.
78
+ *
79
+ * Decided on what the database reported, not on the query's text. Each
80
+ * column of the result says which table column it was read from, or that it
81
+ * was computed; a cell is editable only when it was read from a column of a
82
+ * table, every column of that table's primary key was read too, and each of
83
+ * them is the only column of the result with its name — a row holds one value
84
+ * per name.
85
+ *
86
+ * The text was the only evidence before, and it was wrong twice over. For
87
+ * `SELECT p.id, a.name FROM posts p JOIN authors a …` it took the post's `id`
88
+ * for the author's key and updated whichever author shared the post's id.
89
+ * For `SELECT id, lower(email) AS name FROM users` it wrote the edited text
90
+ * to `users.name`.
91
+ *
92
+ * The database reports a table, not which mention of it: in a self-join both
93
+ * sides are `authors`, and a key from one with a value from the other finds
94
+ * the wrong row. So the query is still read, for one thing — the table must
95
+ * be read once, and nothing it is read through may be read twice.
96
+ */
97
+ export declare function resolveCellEdit(sqlText: string, provenance: ResultProvenance, resultColumn: string): CellEditResolution;
98
+ /**
99
+ * A collection whose records a query's rows can be opened as.
11
100
  */
12
101
  export interface ResolvedQueryCollection {
13
- /** DB table name from the SQL AST (e.g. "blog_posts") */
102
+ /** The table, as the database named it. */
14
103
  tableName: string;
15
- /** SQL alias if present (e.g. "bp") */
16
- tableAlias?: string;
104
+ schemaName: string;
17
105
  /** The matched collection */
18
106
  collection: AdminCollection;
19
- /** Columns from this table that are present in the result set */
20
- columns: string[];
21
- /** The result column name that holds the primary key for this table (e.g. "id", "author_id") */
22
- pkColumn?: string;
107
+ /** The result column that holds the table's primary key. */
108
+ pkColumn: string;
23
109
  }
24
110
  /**
25
- * Extract all tables referenced in a SQL query's FROM and JOIN clauses.
26
- * Returns an empty array for non-SELECT queries or parse failures.
111
+ * The collections a result's rows can be opened as: those whose table one of
112
+ * the result's columns was read from, with the table's (single-column) primary
113
+ * key read too, under a name no other column of the result has.
114
+ *
115
+ * Matched on the provenance the database reported. A text match took the
116
+ * result's `id` for every table of a join: `SELECT p.id, a.name FROM posts p
117
+ * JOIN authors a …` offered to open the author whose id was the post's.
27
118
  */
28
- export declare function extractTablesFromQuery(sqlString: string): ExtractedTable[];
29
- import type { AdminCollection } from "@rebasepro/cms-types";
119
+ export declare function resolveQueryCollections(provenance: ResultProvenance, collections: AdminCollection[]): ResolvedQueryCollection[];
30
120
  /**
31
- * Resolve which collections are referenced by a SQL query.
121
+ * The console's "Format SQL": runs of whitespace become one space, a comma is
122
+ * followed by one, and an `=` standing alone gets one on each side — in the
123
+ * code only.
32
124
  *
33
- * Parses the SQL, extracts table names, and matches each against
34
- * registered collections via `collection.table` (falling back to
35
- * snake_case of `collection.slug`).
125
+ * A literal, a quoted identifier and a comment are copied as they are: their
126
+ * text is data, and rewriting `'a,b'` to `'a, b'` changes the value the next
127
+ * Run writes. A line comment keeps the line break that ends it, or it would
128
+ * swallow the code after it. `>=`, `<=`, `!=`, `:=` and `=>` are operators of
129
+ * their own and are left whole.
130
+ */
131
+ export declare function formatSql(sqlText: string): string;
132
+ /**
133
+ * `sqlText` with the console's automatic `LIMIT` on it, or `null` when it
134
+ * takes none (see {@link acceptsAutoLimit}).
36
135
  *
37
- * For each matched collection, determines which result columns
38
- * belong to that table using the database schema information.
136
+ * The limit goes straight after the statement's last token — before a
137
+ * trailing comment, which would otherwise swallow it, and in place of a
138
+ * trailing `;`. A query that computes an aggregate is limited like any other:
139
+ * the limit applies to the rows it returns, so a `count(*)` still counts every
140
+ * row, and a window `count(*) OVER ()` is no longer a way past the cap.
39
141
  */
40
- export declare function resolveQueryCollections(sqlString: string, schemas: Record<string, TableInfo[]>, collections: AdminCollection[], resultColumns?: string[]): ResolvedQueryCollection[];
41
- export interface PKMapping {
42
- /** The actual column name in the database table */
43
- dbColumn: string;
44
- /** The column name as it appears in the query result set (may be aliased) */
45
- resultColumn: string;
46
- }
47
- export interface TableAndPKResult {
48
- tableName?: string;
49
- primaryKeys?: PKMapping[];
50
- error?: string;
51
- }
52
- export declare function determineTableAndPK(sqlString: string, columnKey: string, schemas: Record<string, TableInfo[]>): TableAndPKResult;
142
+ export declare function withAutoLimit(sqlText: string, limit: number): string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebasepro/studio",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Rebase Studio — the SQL editor, schema visualiser, RLS policy editor and API explorer.",
5
5
  "keywords": [
6
6
  "rebase",
@@ -35,19 +35,19 @@
35
35
  "pgsql-ast-parser": "12.0.2",
36
36
  "prism-react-renderer": "^2.4.1",
37
37
  "react-dropzone": "^19.1.1",
38
- "@rebasepro/cms-types": "0.22.0",
39
- "@rebasepro/client": "0.22.0",
40
- "@rebasepro/app": "0.22.0",
41
- "@rebasepro/common": "0.22.0",
42
- "@rebasepro/utils": "0.22.0",
43
- "@rebasepro/ui": "0.22.0",
44
- "@rebasepro/types": "0.22.0"
38
+ "@rebasepro/cms-types": "0.24.0",
39
+ "@rebasepro/app": "0.24.0",
40
+ "@rebasepro/client": "0.24.0",
41
+ "@rebasepro/ui": "0.24.0",
42
+ "@rebasepro/utils": "0.24.0",
43
+ "@rebasepro/common": "0.24.0",
44
+ "@rebasepro/types": "0.24.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "react": "^19.2.7",
48
48
  "react-dom": "^19.2.7",
49
49
  "react-router": "^8.3.0",
50
- "@rebasepro/cms": "0.22.0"
50
+ "@rebasepro/cms": "0.24.0"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@rebasepro/cms": {