@rebasepro/studio 0.12.0 → 0.12.1-canary.g35be8cb

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 (43) hide show
  1. package/README.md +1 -2
  2. package/dist/{ApiExplorer-VRKWqCVI.js → ApiExplorer-BIN9hE7r.js} +5 -3
  3. package/dist/{ApiExplorer-VRKWqCVI.js.map → ApiExplorer-BIN9hE7r.js.map} +1 -1
  4. package/dist/{ApiKeysView-vQKqt2wl.js → ApiKeysView-dA-OvlcH.js} +3 -2
  5. package/dist/{ApiKeysView-vQKqt2wl.js.map → ApiKeysView-dA-OvlcH.js.map} +1 -1
  6. package/dist/BackupsView-DNS6LdVg.js.map +1 -1
  7. package/dist/{BranchesView-DH0qnU9J.js → BranchesView-Bgmp_1bm.js} +3 -2
  8. package/dist/{BranchesView-DH0qnU9J.js.map → BranchesView-Bgmp_1bm.js.map} +1 -1
  9. package/dist/{CronJobsView-B59i3Mis.js → CronJobsView-CL9q-DX7.js} +5 -3
  10. package/dist/{CronJobsView-B59i3Mis.js.map → CronJobsView-CL9q-DX7.js.map} +1 -1
  11. package/dist/{JSEditor-CIkng40W.js → JSEditor-BSTU-NXF.js} +45 -8
  12. package/dist/JSEditor-BSTU-NXF.js.map +1 -0
  13. package/dist/{LogsExplorer-BCXoGREr.js → LogsExplorer-BsHaHIMK.js} +6 -4
  14. package/dist/LogsExplorer-BsHaHIMK.js.map +1 -0
  15. package/dist/MonacoEditor-D0_QljcX.js.map +1 -1
  16. package/dist/{RLSEditor-rLlMcZPG.js → RLSEditor-DJagnZOw.js} +14 -10
  17. package/dist/RLSEditor-DJagnZOw.js.map +1 -0
  18. package/dist/{SQLEditor-DFYk9r1u.js → SQLEditor-CL6DcGRM.js} +29 -17
  19. package/dist/{SQLEditor-DFYk9r1u.js.map → SQLEditor-CL6DcGRM.js.map} +1 -1
  20. package/dist/{SchemaVisualizer-C-CaHaRK.js → SchemaVisualizer-vxKSG80C.js} +158 -24
  21. package/dist/SchemaVisualizer-vxKSG80C.js.map +1 -0
  22. package/dist/{StorageView-bRwRfwku.js → StorageView-CwDI4sBG.js} +7 -5
  23. package/dist/StorageView-CwDI4sBG.js.map +1 -0
  24. package/dist/components/SchemaVisualizer/useLiveRls.d.ts +3 -0
  25. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -1
  26. package/dist/index.es.js +16 -18
  27. package/dist/index.es.js.map +1 -1
  28. package/package.json +21 -23
  29. package/src/components/ApiExplorer/ApiExplorer.tsx +4 -3
  30. package/src/components/JSEditor/JSEditor.tsx +42 -5
  31. package/src/components/LogsExplorer/LogsExplorer.tsx +4 -3
  32. package/src/components/RLSEditor/RLSEditor.tsx +42 -8
  33. package/src/components/RebaseStudio.tsx +26 -3
  34. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +72 -26
  35. package/src/components/SchemaVisualizer/useLiveRls.ts +86 -0
  36. package/src/components/SchemaVisualizer/useSchemaGraph.ts +30 -6
  37. package/src/components/StorageView/StorageView.tsx +5 -4
  38. package/src/components/StudioHomePage.tsx +1 -1
  39. package/dist/JSEditor-CIkng40W.js.map +0 -1
  40. package/dist/LogsExplorer-BCXoGREr.js.map +0 -1
  41. package/dist/RLSEditor-rLlMcZPG.js.map +0 -1
  42. package/dist/SchemaVisualizer-C-CaHaRK.js.map +0 -1
  43. package/dist/StorageView-bRwRfwku.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/studio",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g35be8cb",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.es.js",
7
7
  "module": "./dist/index.es.js",
@@ -10,26 +10,25 @@
10
10
  "dependencies": {
11
11
  "@monaco-editor/react": "^4.7.0",
12
12
  "@types/dagre": "0.7.54",
13
- "@xyflow/react": "12.11.0",
13
+ "@xyflow/react": "12.11.2",
14
14
  "dagre": "0.8.5",
15
- "monaco-editor": "^0.55.1",
15
+ "monaco-editor": "^0.56.0",
16
16
  "pgsql-ast-parser": "12.0.2",
17
17
  "prism-react-renderer": "^2.4.1",
18
- "react-dropzone": "^15.0.0",
19
- "@rebasepro/admin-types": "0.12.0",
20
- "@rebasepro/app": "0.12.0",
21
- "@rebasepro/client": "0.12.0",
22
- "@rebasepro/common": "0.12.0",
23
- "@rebasepro/types": "0.12.0",
24
- "@rebasepro/utils": "0.12.0",
25
- "@rebasepro/ui": "0.12.0"
18
+ "react-dropzone": "^19.1.1",
19
+ "@rebasepro/client": "0.12.1-canary.g35be8cb",
20
+ "@rebasepro/app": "0.12.1-canary.g35be8cb",
21
+ "@rebasepro/common": "0.12.1-canary.g35be8cb",
22
+ "@rebasepro/types": "0.12.1-canary.g35be8cb",
23
+ "@rebasepro/admin-types": "0.12.1-canary.g35be8cb",
24
+ "@rebasepro/ui": "0.12.1-canary.g35be8cb",
25
+ "@rebasepro/utils": "0.12.1-canary.g35be8cb"
26
26
  },
27
27
  "peerDependencies": {
28
- "react": ">=19.0.0",
29
- "react-dom": ">=19.0.0",
30
- "react-router": "^7.0.0",
31
- "react-router-dom": "^7.0.0",
32
- "@rebasepro/admin": "0.12.0"
28
+ "react": ">=19.2.7",
29
+ "react-dom": ">=19.2.7",
30
+ "react-router": "^8.3.0",
31
+ "@rebasepro/admin": "0.12.1-canary.g35be8cb"
33
32
  },
34
33
  "peerDependenciesMeta": {
35
34
  "@rebasepro/admin": {
@@ -58,20 +57,19 @@
58
57
  },
59
58
  "devDependencies": {
60
59
  "@jest/globals": "^30.4.1",
61
- "@testing-library/jest-dom": "^6.9.1",
60
+ "@testing-library/jest-dom": "^7.0.0",
62
61
  "@testing-library/react": "^16.3.2",
63
62
  "@types/jest": "^30.0.0",
64
- "@types/node": "^25.9.3",
63
+ "@types/node": "^26.1.2",
65
64
  "@types/react": "^19.2.17",
66
65
  "@types/react-dom": "^19.2.3",
67
- "@vitejs/plugin-react": "^6.0.2",
66
+ "@vitejs/plugin-react": "^6.0.4",
68
67
  "babel-plugin-react-compiler": "19.0.0-beta-ebf51a3-20250411",
69
68
  "jest": "^30.4.2",
70
- "react-router": "^7.17.0",
71
- "react-router-dom": "^7.17.0",
72
- "ts-jest": "^29.4.11",
69
+ "react-router": "^8.3.0",
70
+ "ts-jest": "^29.4.12",
73
71
  "typescript": "^6.0.3",
74
- "vite": "^8.0.16"
72
+ "vite": "^8.1.5"
75
73
  },
76
74
  "files": [
77
75
  "dist",
@@ -1,5 +1,5 @@
1
1
  import React, { useState, useEffect, useMemo } from "react";
2
- import { useApiConfig, useAuthController } from "@rebasepro/app";
2
+ import { useApiBase, useApiConfig, useAuthController } from "@rebasepro/app";
3
3
  import {
4
4
  CircularProgress,
5
5
  Typography,
@@ -24,6 +24,7 @@ import { parseOpenApiSpec } from "./parseSpec";
24
24
  */
25
25
  export function ApiExplorer() {
26
26
  const apiConfig = useApiConfig();
27
+ const apiBase = useApiBase();
27
28
  const authController = useAuthController();
28
29
  const apiUrl = apiConfig?.apiUrl;
29
30
 
@@ -39,7 +40,7 @@ export function ApiExplorer() {
39
40
  useEffect(() => {
40
41
  if (!apiUrl) return;
41
42
  let cancelled = false;
42
- const specUrl = `${apiUrl.replace(/\/+$/, "")}/api/docs`;
43
+ const specUrl = `${apiBase}/docs`;
43
44
 
44
45
  (async () => {
45
46
  try {
@@ -66,7 +67,7 @@ export function ApiExplorer() {
66
67
  return () => {
67
68
  cancelled = true;
68
69
  };
69
- }, [apiUrl, apiConfig, authController]);
70
+ }, [apiUrl, apiBase, apiConfig, authController]);
70
71
 
71
72
  // Parse spec into grouped endpoints
72
73
  const { groups, allEndpoints } = useMemo(() => {
@@ -73,17 +73,34 @@ interface EditorTab {
73
73
  const STORAGE_PREFIX = "rebase_js_";
74
74
  const MAX_HISTORY = 50;
75
75
 
76
- const DEFAULT_CODE = `// Available: client (RebaseClient)
76
+ /**
77
+ * The starter script.
78
+ *
79
+ * It runs. That is the point: the console opens with the Run button armed, and
80
+ * a starter that reads `collection("your_collection")` made the first thing a
81
+ * new user did be a 404 — a placeholder presented as working code. `slug` is
82
+ * the project's first collection when one is known.
83
+ */
84
+ const makeDefaultCode = (slug?: string) => `// Available: client (RebaseClient)
77
85
  // Press Cmd+Enter (Ctrl+Enter) to run
78
86
  //
79
87
  // Examples:
80
- // const result = await client.data.collection("your_collection").find({ limit: 10 });
81
88
  // const users = await client.admin.listUsers();
82
89
  // const session = client.auth.getSession();
83
-
84
- const result = await client.data.collection("your_collection").find({ limit: 10 });
90
+ ${slug
91
+ ? `
92
+ const result = await client.data.collection(${JSON.stringify(slug)}).find({ limit: 10 });
85
93
  return result;
86
- `;
94
+ `
95
+ : `
96
+ // Name one of your collections to query it:
97
+ // const result = await client.data.collection("posts").find({ limit: 10 });
98
+ // return result;
99
+
100
+ return client.auth.getSession();
101
+ `}`;
102
+
103
+ const DEFAULT_CODE = makeDefaultCode();
87
104
 
88
105
  // ─── Helpers ─────────────────────────────────────────────────────────
89
106
 
@@ -250,6 +267,26 @@ code: DEFAULT_CODE }])
250
267
 
251
268
  const collectionSlugs = useMemo(() => collectionInfos.map(c => c.slug), [collectionInfos]);
252
269
 
270
+ /**
271
+ * Name a real collection in the starter script once the registry knows one.
272
+ *
273
+ * The registry is empty on first paint — in the hosted console it arrives
274
+ * over the network — so the placeholder-free default cannot be built in the
275
+ * `useState` initialiser. Rewriting only a tab whose content is still
276
+ * *exactly* the untouched default means this can never overwrite anything
277
+ * anyone typed, and never fires twice.
278
+ */
279
+ const firstSlug = collectionSlugs[0];
280
+ useEffect(() => {
281
+ if (!firstSlug) return;
282
+ setTabs(prev => {
283
+ if (!prev.some(tab => tab.code === DEFAULT_CODE)) return prev;
284
+ const seeded = makeDefaultCode(firstSlug);
285
+ return prev.map(tab => (tab.code === DEFAULT_CODE ? { ...tab,
286
+ code: seeded } : tab));
287
+ });
288
+ }, [firstSlug]);
289
+
253
290
  // Users list for the picker — mapped to SelectableUser shape
254
291
  const users = useMemo((): SelectableUser[] => {
255
292
  const managed: SelectableUser[] = [];
@@ -1,6 +1,6 @@
1
1
  import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from "@rebasepro/ui";
3
- import { useApiConfig } from "@rebasepro/app";
3
+ import { useApiBase, useApiConfig } from "@rebasepro/app";
4
4
 
5
5
  interface LogEntry {
6
6
  id: string;
@@ -56,6 +56,7 @@ export function LogsExplorer() {
56
56
  const stickRef = useRef(true);
57
57
  const lastMaxIdRef = useRef<number | null>(null);
58
58
  const apiConfig = useApiConfig();
59
+ const apiBase = useApiBase();
59
60
 
60
61
  // Debounce the search box so typing doesn't refetch per keystroke.
61
62
  useEffect(() => {
@@ -82,7 +83,7 @@ export function LogsExplorer() {
82
83
  const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
83
84
  if (token) headers["Authorization"] = `Bearer ${token}`;
84
85
 
85
- const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
86
+ const resp = await fetch(`${apiBase}/logs?${params}`, { headers });
86
87
  if (!resp.ok) {
87
88
  setError(resp.status === 401 || resp.status === 403
88
89
  ? "Not authorised to read logs — an admin role is required."
@@ -108,7 +109,7 @@ export function LogsExplorer() {
108
109
  } catch (e) {
109
110
  setError(e instanceof Error ? e.message : "Could not load logs.");
110
111
  }
111
- }, [level, source, search, apiConfig]);
112
+ }, [level, source, search, apiConfig, apiBase]);
112
113
 
113
114
  // A filter change replaces the window wholesale — "new entries since last
114
115
  // poll" stops meaning anything, so the counter starts over.
@@ -1,5 +1,6 @@
1
1
 
2
- import { useStudioCollectionRegistry } from "@rebasepro/app";
2
+ import { useStudioCollectionRegistry, useStudioCapabilities } from "@rebasepro/app";
3
+ import { useApiBase } from "@rebasepro/app";
3
4
  import React, { useState, useEffect, useCallback, useMemo } from "react";
4
5
  import {
5
6
  Alert,
@@ -154,6 +155,10 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
154
155
  const { databaseAdmin } = useRebaseContext();
155
156
  const snackbarController = useSnackbarController();
156
157
  const collectionRegistry = useStudioCollectionRegistry();
158
+ const { codebase: hasCodebase } = useStudioCapabilities();
159
+ /* `apiUrl` is a bare origin; the routes live under the backend's
160
+ `basePath`, which is `/api` only by default. */
161
+ const apiBase = useApiBase() ?? `${apiUrl.replace(/\/+$/, "")}/api`;
157
162
  const { t } = useTranslation();
158
163
 
159
164
  const [isLoading, setIsLoading] = useState(true);
@@ -731,7 +736,26 @@ message: e instanceof Error ? e.message : String(e) });
731
736
  schema={activeTableData.schemaName}
732
737
  table={activeTableData.tableName}
733
738
  onSave={async (newPolicy) => {
734
- if (activeCollection) {
739
+ /*
740
+ * Where a policy for a *mapped* table belongs depends
741
+ * on the host, not on the table.
742
+ *
743
+ * Beside its own source, the collection file is the
744
+ * right home: the rule is checked in, and the next
745
+ * migration applies it. The hosted console has no
746
+ * source — the container is rebuilt from the
747
+ * customer's repository on every deploy — and the
748
+ * schema-editor routes it would POST to are not even
749
+ * mounted, because the framework switches them off
750
+ * under NODE_ENV=production, which every tenant runs.
751
+ * So this branch used to make "Create Policy" and
752
+ * "Edit" fail with "Failed to save policy" on every
753
+ * mapped table in the console, which is most of them.
754
+ * There, the database is the only place a policy can
755
+ * live, so write it there — the same path unmapped
756
+ * tables have always used.
757
+ */
758
+ if (activeCollection && hasCodebase) {
735
759
  // Collection-mapped table: save via schema-editor API
736
760
  const rule: Record<string, unknown> = {
737
761
  name: newPolicy.policyname,
@@ -751,7 +775,7 @@ message: e instanceof Error ? e.message : String(e) });
751
775
  }
752
776
 
753
777
  try {
754
- const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
778
+ const response = await fetch(`${apiBase}/schema-editor/collection/save`, {
755
779
  method: "POST",
756
780
  headers: { "Content-Type": "application/json" },
757
781
  body: JSON.stringify({
@@ -770,7 +794,9 @@ message: "Policy saved successfully" });
770
794
  message: e instanceof Error ? e.message : String(e) });
771
795
  }
772
796
  } else {
773
- // Non-collection table (internal/junction/unmapped): apply policy directly via SQL
797
+ // No codebase to write to (hosted console), or an
798
+ // unmapped table (internal/junction/other): apply
799
+ // the policy to the database directly.
774
800
  try {
775
801
  const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(activeTableData.tableName)}`;
776
802
  const policyName = sanitizeSqlIdentifier(newPolicy.policyname || "unnamed_policy");
@@ -795,7 +821,7 @@ message: e instanceof Error ? e.message : String(e) });
795
821
  await databaseAdmin!.executeSql!(sql);
796
822
 
797
823
  snackbarController.open({ type: "success",
798
- message: `Policy "${newPolicy.policyname}" applied directly via SQL` });
824
+ message: `Policy "${newPolicy.policyname}" applied to the database` });
799
825
  setEditingPolicy(null);
800
826
  fetchRLSData();
801
827
  } catch (e: unknown) {
@@ -907,7 +933,12 @@ message: e instanceof Error ? e.message : String(e) });
907
933
  </div>
908
934
  </Tooltip>
909
935
  )}
910
- {policy.status === "live" && (
936
+ {/* "DB Only" is a *drift* signal — it means the
937
+ codebase does not declare this policy. Where
938
+ there is no codebase to compare against, every
939
+ policy trivially qualifies, so the badge said
940
+ nothing and said it about everything. */}
941
+ {policy.status === "live" && hasCodebase && (
911
942
  <Tooltip title="This policy is live in the database but missing from your codebase schema.">
912
943
  <div className="px-1.5 py-0.5 rounded text-[10px] uppercase bg-orange-500/10 text-orange-600 border border-orange-500/20 shrink-0">
913
944
  DB Only
@@ -921,7 +952,10 @@ message: e instanceof Error ? e.message : String(e) });
921
952
  </div>
922
953
  </div>
923
954
  <div className="flex gap-2 shrink-0 items-center">
924
- {policy.status === "live" && activeCollection && (
955
+ {/* Writes a collection source file, so it needs a
956
+ codebase on the other end. In the console the
957
+ endpoint is not mounted and the button 404'd. */}
958
+ {policy.status === "live" && activeCollection && hasCodebase && (
925
959
  <Button
926
960
  size="small"
927
961
  variant="outlined"
@@ -940,7 +974,7 @@ message: e instanceof Error ? e.message : String(e) });
940
974
  const newRules = [...existingRules, rule];
941
975
 
942
976
  try {
943
- const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
977
+ const response = await fetch(`${apiBase}/schema-editor/collection/save`, {
944
978
  method: "POST",
945
979
  headers: { "Content-Type": "application/json" },
946
980
  body: JSON.stringify({
@@ -34,6 +34,21 @@ export function RebaseStudio({ tools, homePage }: RebaseStudioConfig) {
34
34
 
35
35
  const resolvedHomePage = homePage ?? DEFAULT_HOME_PAGE;
36
36
 
37
+ /**
38
+ * The tool list reduced to a value, so that re-rendering the host does not
39
+ * republish the registry.
40
+ *
41
+ * `tools` is an array, and callers write it inline — `tools={[...TOOLS]}`
42
+ * in the hosted console. A fresh array every render meant a fresh
43
+ * `devViews` every render, which meant the effect below unregistered and
44
+ * re-registered on every render. Because each registration builds *new*
45
+ * `<Suspense><SQLEditor/></Suspense>` elements, whatever view was on screen
46
+ * was torn down and remounted each time: a half-typed SQL query, a storage
47
+ * folder you had navigated into, an open policy editor — all discarded, and
48
+ * every mount reissued the view's initial requests.
49
+ */
50
+ const toolsKey = tools ? tools.join(",") : "";
51
+
37
52
  const devViews: AppView[] = useMemo(() => {
38
53
  const views: AppView[] = [];
39
54
  const activeTools = tools ?? ["sql", "js", "rls", "storage", "cron", "schema-visualizer", "branches", "backups", "api", "logs", "api-keys"];
@@ -130,19 +145,27 @@ view: suspense(<ApiKeysView/>) });
130
145
  // Note: "schema" tool is auto-injected by RebaseShell when collectionEditor is enabled.
131
146
  // It is NOT registered here anymore.
132
147
  return views;
133
- }, [tools]);
148
+ // Keyed by the tool list's *contents*; see `toolsKey`. `tools` itself is
149
+ // read inside, and is exactly determined by the key.
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [toolsKey]);
134
152
 
135
153
  // Use a ref for homePage so it never destabilizes the effect.
136
154
  // homePage is a React element — its identity doesn't matter for registration.
137
155
  const homePageRef = React.useRef(resolvedHomePage);
138
156
  homePageRef.current = resolvedHomePage;
139
157
 
158
+ // Same reasoning for `tools`: it is registered as config, but a new array
159
+ // with the same contents must not count as a change.
160
+ const toolsRef = React.useRef(tools);
161
+ toolsRef.current = tools;
162
+
140
163
  useLayoutEffect(() => {
141
- dispatch.registerStudio({ tools,
164
+ dispatch.registerStudio({ tools: toolsRef.current,
142
165
  homePage: homePageRef.current,
143
166
  devViews });
144
167
  return () => dispatch.unregisterStudio();
145
- }, [dispatch, tools, devViews]);
168
+ }, [dispatch, devViews]);
146
169
 
147
170
  return null;
148
171
  }
@@ -6,6 +6,7 @@ import {
6
6
  Background,
7
7
  BackgroundVariant,
8
8
  useReactFlow,
9
+ useNodesInitialized,
9
10
  ReactFlowProvider,
10
11
  applyNodeChanges,
11
12
  applyEdgeChanges
@@ -32,6 +33,7 @@ import {
32
33
 
33
34
  import { isPostgresCollectionConfig } from "@rebasepro/types";
34
35
  import { useSchemaGraph } from "./useSchemaGraph";
36
+ import { useLiveRlsTables } from "./useLiveRls";
35
37
  import type { TableNodeData } from "./useSchemaGraph";
36
38
  import { TableNode } from "./TableNode";
37
39
  import { RelationEdge } from "./RelationEdge";
@@ -55,6 +57,7 @@ function SchemaVisualizerCanvas({
55
57
  collections: AdminCollection[];
56
58
  }) {
57
59
  const reactFlowInstance = useReactFlow();
60
+ const liveRls = useLiveRlsTables();
58
61
  const {
59
62
  nodes: layoutedNodes,
60
63
  edges: layoutedEdges,
@@ -63,7 +66,7 @@ function SchemaVisualizerCanvas({
63
66
  relayout,
64
67
  tableCount,
65
68
  relationCount
66
- } = useSchemaGraph(collections);
69
+ } = useSchemaGraph(collections, liveRls);
67
70
 
68
71
  const [nodes, setNodes] = useState<Node[]>([]);
69
72
  const [edges, setEdges] = useState<Edge[]>([]);
@@ -86,18 +89,43 @@ function SchemaVisualizerCanvas({
86
89
  []
87
90
  );
88
91
 
89
- // Fit view on first load
92
+ /**
93
+ * Fit the graph into view, once, as soon as it can be measured.
94
+ *
95
+ * This used to fire on a 200ms timer. `fitView` needs every node's real
96
+ * width and height, which React Flow only knows after it has laid them out
97
+ * and measured them — with a dozen tables, a cold Monaco-sized bundle and
98
+ * a slow first paint, 200ms is often too early. The fit then ran against
99
+ * zero-sized nodes and produced a viewport nothing lines up with, and
100
+ * `initialFitDone` made sure it was never retried: the ERD opened as a
101
+ * scatter of specks in a corner and stayed there until you hit "fit" by
102
+ * hand. `useNodesInitialized` is the event this was approximating.
103
+ */
104
+ const nodesInitialized = useNodesInitialized();
90
105
  useEffect(() => {
91
- if (nodes.length > 0 && !initialFitDone.current) {
92
- const timer = setTimeout(() => {
93
- reactFlowInstance.fitView({ padding: 0.15,
106
+ if (!nodesInitialized || nodes.length === 0 || initialFitDone.current) return;
107
+ initialFitDone.current = true;
108
+ reactFlowInstance.fitView({ padding: 0.15,
94
109
  duration: 400 });
95
- initialFitDone.current = true;
96
- }, 200);
97
- return () => clearTimeout(timer);
98
- }
99
- return undefined;
100
- }, [nodes.length, reactFlowInstance]);
110
+ }, [nodesInitialized, nodes.length, reactFlowInstance]);
111
+
112
+ /**
113
+ * Re-fit after a relayout.
114
+ *
115
+ * Switching LR↔TB transposes the graph — a tall column becomes a wide band
116
+ * — so the viewport that framed the old shape frames the new one badly:
117
+ * asking for a different layout used to leave most of it off-screen until
118
+ * you pressed "fit" as a second step. Skipped for the very first layout,
119
+ * which the initial fit above already handles.
120
+ */
121
+ const lastFittedDirection = useRef(direction);
122
+ useEffect(() => {
123
+ if (!nodesInitialized || nodes.length === 0) return;
124
+ if (lastFittedDirection.current === direction) return;
125
+ lastFittedDirection.current = direction;
126
+ reactFlowInstance.fitView({ padding: 0.15,
127
+ duration: 400 });
128
+ }, [direction, nodesInitialized, nodes, reactFlowInstance]);
101
129
 
102
130
  const handleFitView = useCallback(() => {
103
131
  reactFlowInstance.fitView({ padding: 0.15,
@@ -176,24 +204,40 @@ duration: 400 }
176
204
  [nodes]
177
205
  );
178
206
 
207
+ /**
208
+ * The RLS answer for one collection, from the same place the graph used.
209
+ *
210
+ * `nodes` already carries it, so look it up there rather than re-deriving —
211
+ * that is what kept the sidebar dots, the node badges and the total from
212
+ * agreeing.
213
+ */
214
+ const rlsEnabledFor = useCallback(
215
+ (collection: AdminCollection): boolean => {
216
+ const table = (isPostgresCollectionConfig(collection) ? collection.table : undefined) ?? collection.slug;
217
+ const node = nodes.find((n) => n.id === `table-${table}`);
218
+ return Boolean((node?.data as TableNodeData | undefined)?.rlsEnabled);
219
+ },
220
+ [nodes]
221
+ );
222
+
179
223
  // Stats
180
224
  const stats = useMemo(
181
225
  () => ({
182
226
  tables: tableCount,
183
227
  relations: relationCount,
184
228
  junctions: junctionNodes.length,
185
- withRls: postgresCollections.filter(
186
- (c) =>
187
- isPostgresCollectionConfig(c) &&
188
- c.securityRules &&
189
- c.securityRules.length > 0
190
- ).length
229
+ // Counted off the nodes, which already carry the database's answer
230
+ // where there was one — the same number the per-table badges show.
231
+ // Counting `securityRules` here instead made the total disagree
232
+ // with the badges, and read 0 in the hosted console, where the
233
+ // contract endpoint strips those rules.
234
+ withRls: nodes.filter((n) => (n.data as TableNodeData | undefined)?.rlsEnabled).length
191
235
  }),
192
236
  [
193
237
  tableCount,
194
238
  relationCount,
195
239
  junctionNodes.length,
196
- postgresCollections
240
+ nodes
197
241
  ]
198
242
  );
199
243
 
@@ -293,14 +337,16 @@ duration: 400 }
293
337
  )}
294
338
  </div>
295
339
  <div className="flex items-center gap-1 shrink-0 ml-1">
296
- {isPostgresCollectionConfig(collection) &&
297
- collection.securityRules &&
298
- collection.securityRules
299
- .length > 0 && (
300
- <Tooltip title="RLS enabled">
301
- <div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
302
- </Tooltip>
303
- )}
340
+ {/* Same source as the node badge and the
341
+ "RLS protected" total: the database when
342
+ it could be asked, the config otherwise.
343
+ Reading `securityRules` directly here made
344
+ the three disagree. */}
345
+ {rlsEnabledFor(collection) && (
346
+ <Tooltip title="RLS enabled">
347
+ <div className="w-1.5 h-1.5 rounded-full bg-green-500"/>
348
+ </Tooltip>
349
+ )}
304
350
  {collection.history && (
305
351
  <Tooltip title="History enabled">
306
352
  <div className="w-1.5 h-1.5 rounded-full bg-blue-400"/>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Which tables actually have row-level security switched on, according to the
3
+ * database.
4
+ *
5
+ * ## Why this is not read from the collection config
6
+ *
7
+ * The graph used to derive "RLS protected" from `collection.securityRules` —
8
+ * i.e. from what the *codebase* declares. Two problems with that:
9
+ *
10
+ * - **It is not the truth.** Rules in code are only live once a migration has
11
+ * applied them, and `ALTER TABLE … DISABLE ROW LEVEL SECURITY` in a psql
12
+ * session is invisible to it. A security indicator that reports intent
13
+ * rather than state is worse than none: it is green precisely when you most
14
+ * need it to be red.
15
+ * - **The hosted console has no codebase.** There, collections arrive from the
16
+ * tenant's `/api/meta/contract`, which deliberately strips `securityRules`
17
+ * (they carry the raw SQL of every policy guarding the project). So the
18
+ * console showed "RLS protected: 0" and no badge on any table, for every
19
+ * project, however well secured.
20
+ *
21
+ * `pg_tables.rowsecurity` answers the question directly and is available
22
+ * wherever `databaseAdmin` is — which is both hosts.
23
+ *
24
+ * When there is no SQL capability at all the hook reports `null`, and callers
25
+ * fall back to the config-derived flag rather than claiming everything is
26
+ * unprotected.
27
+ */
28
+ import { useEffect, useState } from "react";
29
+ import { useRebaseContext } from "@rebasepro/app";
30
+
31
+ /** `schema.table` for every table with RLS enabled, or `null` if unknown. */
32
+ export type LiveRlsTables = Set<string> | null;
33
+
34
+ const RLS_TABLES_SQL = `
35
+ SELECT schemaname, tablename, rowsecurity
36
+ FROM pg_tables
37
+ WHERE schemaname NOT IN ('information_schema', 'pg_catalog');
38
+ `;
39
+
40
+ /** Postgres column names arrive lower-cased over the wire, but not on every driver. */
41
+ function pick<T>(row: Record<string, unknown>, name: string): T | undefined {
42
+ return (row[name] ?? row[name.toUpperCase()]) as T | undefined;
43
+ }
44
+
45
+ function extractRows(result: unknown): Record<string, unknown>[] {
46
+ if (result && typeof result === "object" && "rows" in result) {
47
+ const rows = (result as { rows: unknown }).rows;
48
+ if (Array.isArray(rows)) return rows as Record<string, unknown>[];
49
+ }
50
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
51
+ return [];
52
+ }
53
+
54
+ export function useLiveRlsTables(): LiveRlsTables {
55
+ const { databaseAdmin } = useRebaseContext();
56
+ const executeSql = databaseAdmin?.executeSql;
57
+ const [tables, setTables] = useState<LiveRlsTables>(null);
58
+
59
+ useEffect(() => {
60
+ if (!executeSql) {
61
+ setTables(null);
62
+ return;
63
+ }
64
+ let cancelled = false;
65
+ executeSql(RLS_TABLES_SQL)
66
+ .then((result: unknown) => {
67
+ if (cancelled) return;
68
+ const enabled = new Set<string>();
69
+ for (const row of extractRows(result)) {
70
+ if (!pick<boolean>(row, "rowsecurity")) continue;
71
+ const schema = pick<string>(row, "schemaname") ?? "public";
72
+ const table = pick<string>(row, "tablename");
73
+ if (table) enabled.add(`${schema}.${table}`);
74
+ }
75
+ setTables(enabled);
76
+ })
77
+ .catch(() => {
78
+ // No answer is not the same as "nothing is protected" — leave it
79
+ // unknown so the caller keeps its config-derived fallback.
80
+ if (!cancelled) setTables(null);
81
+ });
82
+ return () => { cancelled = true; };
83
+ }, [executeSql]);
84
+
85
+ return tables;
86
+ }