@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.
- package/README.md +1 -2
- package/dist/{ApiExplorer-VRKWqCVI.js → ApiExplorer-BIN9hE7r.js} +5 -3
- package/dist/{ApiExplorer-VRKWqCVI.js.map → ApiExplorer-BIN9hE7r.js.map} +1 -1
- package/dist/{ApiKeysView-vQKqt2wl.js → ApiKeysView-dA-OvlcH.js} +3 -2
- package/dist/{ApiKeysView-vQKqt2wl.js.map → ApiKeysView-dA-OvlcH.js.map} +1 -1
- package/dist/BackupsView-DNS6LdVg.js.map +1 -1
- package/dist/{BranchesView-DH0qnU9J.js → BranchesView-Bgmp_1bm.js} +3 -2
- package/dist/{BranchesView-DH0qnU9J.js.map → BranchesView-Bgmp_1bm.js.map} +1 -1
- package/dist/{CronJobsView-B59i3Mis.js → CronJobsView-CL9q-DX7.js} +5 -3
- package/dist/{CronJobsView-B59i3Mis.js.map → CronJobsView-CL9q-DX7.js.map} +1 -1
- package/dist/{JSEditor-CIkng40W.js → JSEditor-BSTU-NXF.js} +45 -8
- package/dist/JSEditor-BSTU-NXF.js.map +1 -0
- package/dist/{LogsExplorer-BCXoGREr.js → LogsExplorer-BsHaHIMK.js} +6 -4
- package/dist/LogsExplorer-BsHaHIMK.js.map +1 -0
- package/dist/MonacoEditor-D0_QljcX.js.map +1 -1
- package/dist/{RLSEditor-rLlMcZPG.js → RLSEditor-DuzofJ8t.js} +16 -12
- package/dist/RLSEditor-DuzofJ8t.js.map +1 -0
- package/dist/{SQLEditor-DFYk9r1u.js → SQLEditor-CL6DcGRM.js} +29 -17
- package/dist/{SQLEditor-DFYk9r1u.js.map → SQLEditor-CL6DcGRM.js.map} +1 -1
- package/dist/{SchemaVisualizer-C-CaHaRK.js → SchemaVisualizer-vxKSG80C.js} +158 -24
- package/dist/SchemaVisualizer-vxKSG80C.js.map +1 -0
- package/dist/{StorageView-bRwRfwku.js → StorageView-CwDI4sBG.js} +7 -5
- package/dist/StorageView-CwDI4sBG.js.map +1 -0
- package/dist/components/SchemaVisualizer/useLiveRls.d.ts +3 -0
- package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +6 -1
- package/dist/index.es.js +16 -18
- package/dist/index.es.js.map +1 -1
- package/package.json +21 -23
- package/src/components/ApiExplorer/ApiExplorer.tsx +4 -3
- package/src/components/JSEditor/JSEditor.tsx +42 -5
- package/src/components/LogsExplorer/LogsExplorer.tsx +4 -3
- package/src/components/RLSEditor/RLSEditor.tsx +59 -12
- package/src/components/RebaseStudio.tsx +26 -3
- package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +72 -26
- package/src/components/SchemaVisualizer/useLiveRls.ts +86 -0
- package/src/components/SchemaVisualizer/useSchemaGraph.ts +30 -6
- package/src/components/StorageView/StorageView.tsx +5 -4
- package/src/components/StudioHomePage.tsx +1 -1
- package/src/utils/parseSpec.test.ts +9 -3
- package/dist/JSEditor-CIkng40W.js.map +0 -1
- package/dist/LogsExplorer-BCXoGREr.js.map +0 -1
- package/dist/RLSEditor-rLlMcZPG.js.map +0 -1
- package/dist/SchemaVisualizer-C-CaHaRK.js.map +0 -1
- 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.
|
|
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.
|
|
13
|
+
"@xyflow/react": "12.11.2",
|
|
14
14
|
"dagre": "0.8.5",
|
|
15
|
-
"monaco-editor": "^0.
|
|
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": "^
|
|
19
|
-
"@rebasepro/admin-types": "0.
|
|
20
|
-
"@rebasepro/app": "0.
|
|
21
|
-
"@rebasepro/
|
|
22
|
-
"@rebasepro/
|
|
23
|
-
"@rebasepro/
|
|
24
|
-
"@rebasepro/utils": "0.
|
|
25
|
-
"@rebasepro/
|
|
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.
|
|
29
|
-
"react-dom": ">=19.
|
|
30
|
-
"react-router": "^
|
|
31
|
-
"
|
|
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": "^
|
|
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": "^
|
|
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.
|
|
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": "^
|
|
71
|
-
"
|
|
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.
|
|
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 = `${
|
|
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
|
-
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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(`${
|
|
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
|
-
//
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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(`${
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
if (!nodesInitialized || nodes.length === 0 || initialFitDone.current) return;
|
|
107
|
+
initialFitDone.current = true;
|
|
108
|
+
reactFlowInstance.fitView({ padding: 0.15,
|
|
94
109
|
duration: 400 });
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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"/>
|