@rebasepro/studio 0.12.1-canary.gf5f1d39 → 0.13.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 (44) 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-DuzofJ8t.js} +16 -12
  17. package/dist/RLSEditor-DuzofJ8t.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 +59 -12
  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/src/utils/parseSpec.test.ts +9 -3
  40. package/dist/JSEditor-CIkng40W.js.map +0 -1
  41. package/dist/LogsExplorer-BCXoGREr.js.map +0 -1
  42. package/dist/RLSEditor-rLlMcZPG.js.map +0 -1
  43. package/dist/SchemaVisualizer-C-CaHaRK.js.map +0 -1
  44. 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.1-canary.gf5f1d39",
4
+ "version": "0.13.0",
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.1-canary.gf5f1d39",
20
- "@rebasepro/app": "0.12.1-canary.gf5f1d39",
21
- "@rebasepro/client": "0.12.1-canary.gf5f1d39",
22
- "@rebasepro/common": "0.12.1-canary.gf5f1d39",
23
- "@rebasepro/types": "0.12.1-canary.gf5f1d39",
24
- "@rebasepro/utils": "0.12.1-canary.gf5f1d39",
25
- "@rebasepro/ui": "0.12.1-canary.gf5f1d39"
18
+ "react-dropzone": "^19.1.1",
19
+ "@rebasepro/admin-types": "0.13.0",
20
+ "@rebasepro/app": "0.13.0",
21
+ "@rebasepro/common": "0.13.0",
22
+ "@rebasepro/types": "0.13.0",
23
+ "@rebasepro/ui": "0.13.0",
24
+ "@rebasepro/utils": "0.13.0",
25
+ "@rebasepro/client": "0.13.0"
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.1-canary.gf5f1d39"
28
+ "react": ">=19.2.7",
29
+ "react-dom": ">=19.2.7",
30
+ "react-router": "^8.3.0",
31
+ "@rebasepro/admin": "0.13.0"
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,
@@ -29,7 +30,7 @@ import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } fr
29
30
  import { isPostgresCollectionConfig } from "@rebasepro/types";
30
31
  import { REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_PREFIXES, JUNCTION_TABLES_SQL } from "@rebasepro/common";
31
32
  import { getPolicyNamesForRule, getPolicyNamesForRules, getPolicyOperations } from "@rebasepro/utils";
32
- import { resolveJunctionSpecs, getJunctionSecurityRules } from "@rebasepro/common";
33
+ import { resolveJunctionSpecs, getJunctionSecurityRules, getEffectiveSecurityRules } from "@rebasepro/common";
33
34
  import { PolicyEditor } from "./PolicyEditor";
34
35
 
35
36
  type TableCategory = "collection" | "junction" | "internal" | "other";
@@ -65,7 +66,7 @@ function sanitizeSqlIdentifier(name: string): string {
65
66
 
66
67
  // Re-exported for the components that used to import it from here.
67
68
  export type { PostgresPolicy } from "@rebasepro/types";
68
- import type { PostgresPolicy } from "@rebasepro/types";
69
+ import type { CollectionConfig, PostgresPolicy } from "@rebasepro/types";
69
70
 
70
71
  interface TableRLSStatus {
71
72
  schemaName: string;
@@ -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);
@@ -388,8 +393,21 @@ status: "live" };
388
393
  // `<table>_<op>_<hash>`, one per operation. Skipping those rules left their
389
394
  // live policies looking like hand-written SQL ("DB Only"), so derive the
390
395
  // names the generator would emit and match on those.
391
- if (activeCollection && isPostgresCollectionConfig(activeCollection) && activeCollection.securityRules) {
392
- activeCollection.securityRules.forEach((rule) => {
396
+ //
397
+ // `getEffectiveSecurityRules` rather than `securityRules`, for the same
398
+ // reason one step further out: the generator also injects the
399
+ // safe-by-default baseline (`<table>_default_admin_read` and the three
400
+ // `_default_admin_write_*`), which appears in no collection's
401
+ // `securityRules`. Reading the declared rules alone made four policies
402
+ // *Rebase itself wrote* look like drift on every table in the project —
403
+ // badged "DB Only" and offered for import back into the codebase that
404
+ // produced them. The admin panel's own RLS tab already derived them this
405
+ // way; this view, its sibling, did not. Both start from
406
+ // `getEffectiveSecurityRules` now — this one needs the rule bodies to
407
+ // render a policy row, the other only the names it compiles to
408
+ // (`getGeneratedPolicyNames`).
409
+ if (activeCollection && isPostgresCollectionConfig(activeCollection)) {
410
+ getEffectiveSecurityRules(activeCollection as unknown as CollectionConfig).forEach((rule) => {
393
411
  const ops = getPolicyOperations(rule);
394
412
  const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
395
413
 
@@ -731,7 +749,26 @@ message: e instanceof Error ? e.message : String(e) });
731
749
  schema={activeTableData.schemaName}
732
750
  table={activeTableData.tableName}
733
751
  onSave={async (newPolicy) => {
734
- if (activeCollection) {
752
+ /*
753
+ * Where a policy for a *mapped* table belongs depends
754
+ * on the host, not on the table.
755
+ *
756
+ * Beside its own source, the collection file is the
757
+ * right home: the rule is checked in, and the next
758
+ * migration applies it. The hosted console has no
759
+ * source — the container is rebuilt from the
760
+ * customer's repository on every deploy — and the
761
+ * schema-editor routes it would POST to are not even
762
+ * mounted, because the framework switches them off
763
+ * under NODE_ENV=production, which every tenant runs.
764
+ * So this branch used to make "Create Policy" and
765
+ * "Edit" fail with "Failed to save policy" on every
766
+ * mapped table in the console, which is most of them.
767
+ * There, the database is the only place a policy can
768
+ * live, so write it there — the same path unmapped
769
+ * tables have always used.
770
+ */
771
+ if (activeCollection && hasCodebase) {
735
772
  // Collection-mapped table: save via schema-editor API
736
773
  const rule: Record<string, unknown> = {
737
774
  name: newPolicy.policyname,
@@ -751,7 +788,7 @@ message: e instanceof Error ? e.message : String(e) });
751
788
  }
752
789
 
753
790
  try {
754
- const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
791
+ const response = await fetch(`${apiBase}/schema-editor/collection/save`, {
755
792
  method: "POST",
756
793
  headers: { "Content-Type": "application/json" },
757
794
  body: JSON.stringify({
@@ -770,7 +807,9 @@ message: "Policy saved successfully" });
770
807
  message: e instanceof Error ? e.message : String(e) });
771
808
  }
772
809
  } else {
773
- // Non-collection table (internal/junction/unmapped): apply policy directly via SQL
810
+ // No codebase to write to (hosted console), or an
811
+ // unmapped table (internal/junction/other): apply
812
+ // the policy to the database directly.
774
813
  try {
775
814
  const qualifiedTable = `${sanitizeSqlIdentifier(activeTableData.schemaName)}.${sanitizeSqlIdentifier(activeTableData.tableName)}`;
776
815
  const policyName = sanitizeSqlIdentifier(newPolicy.policyname || "unnamed_policy");
@@ -795,7 +834,7 @@ message: e instanceof Error ? e.message : String(e) });
795
834
  await databaseAdmin!.executeSql!(sql);
796
835
 
797
836
  snackbarController.open({ type: "success",
798
- message: `Policy "${newPolicy.policyname}" applied directly via SQL` });
837
+ message: `Policy "${newPolicy.policyname}" applied to the database` });
799
838
  setEditingPolicy(null);
800
839
  fetchRLSData();
801
840
  } catch (e: unknown) {
@@ -907,7 +946,12 @@ message: e instanceof Error ? e.message : String(e) });
907
946
  </div>
908
947
  </Tooltip>
909
948
  )}
910
- {policy.status === "live" && (
949
+ {/* "DB Only" is a *drift* signal — it means the
950
+ codebase does not declare this policy. Where
951
+ there is no codebase to compare against, every
952
+ policy trivially qualifies, so the badge said
953
+ nothing and said it about everything. */}
954
+ {policy.status === "live" && hasCodebase && (
911
955
  <Tooltip title="This policy is live in the database but missing from your codebase schema.">
912
956
  <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
957
  DB Only
@@ -921,7 +965,10 @@ message: e instanceof Error ? e.message : String(e) });
921
965
  </div>
922
966
  </div>
923
967
  <div className="flex gap-2 shrink-0 items-center">
924
- {policy.status === "live" && activeCollection && (
968
+ {/* Writes a collection source file, so it needs a
969
+ codebase on the other end. In the console the
970
+ endpoint is not mounted and the button 404'd. */}
971
+ {policy.status === "live" && activeCollection && hasCodebase && (
925
972
  <Button
926
973
  size="small"
927
974
  variant="outlined"
@@ -940,7 +987,7 @@ message: e instanceof Error ? e.message : String(e) });
940
987
  const newRules = [...existingRules, rule];
941
988
 
942
989
  try {
943
- const response = await fetch(`${apiUrl}/api/schema-editor/collection/save`, {
990
+ const response = await fetch(`${apiBase}/schema-editor/collection/save`, {
944
991
  method: "POST",
945
992
  headers: { "Content-Type": "application/json" },
946
993
  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"/>