@rebasepro/studio 0.8.0 → 0.9.1-canary.09aaf62

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 (78) hide show
  1. package/README.md +4 -4
  2. package/dist/{ApiExplorer-CdIwR9Ga.js → ApiExplorer-B9M9-uy_.js} +3 -3
  3. package/dist/ApiExplorer-B9M9-uy_.js.map +1 -0
  4. package/dist/{ApiKeysView-D-_FSlNL.js → ApiKeysView-vQKqt2wl.js} +45 -3
  5. package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
  6. package/dist/{AuthSimulationSelector-iEZ-Or_1.js → AuthSimulationSelector-Bougd521.js} +2 -2
  7. package/dist/AuthSimulationSelector-Bougd521.js.map +1 -0
  8. package/dist/BackupsView-DNS6LdVg.js +227 -0
  9. package/dist/BackupsView-DNS6LdVg.js.map +1 -0
  10. package/dist/{BranchesView-Dlg78EQ8.js → BranchesView-DH0qnU9J.js} +2 -2
  11. package/dist/BranchesView-DH0qnU9J.js.map +1 -0
  12. package/dist/{CronJobsView-4gdtJvoe.js → CronJobsView-B59i3Mis.js} +2 -2
  13. package/dist/CronJobsView-B59i3Mis.js.map +1 -0
  14. package/dist/{JSEditor-Ca4XYGRp.js → JSEditor-BeHHuxhA.js} +28 -32
  15. package/dist/JSEditor-BeHHuxhA.js.map +1 -0
  16. package/dist/LogsExplorer-BCXoGREr.js +298 -0
  17. package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
  18. package/dist/{MonacoEditor-COZqrIJ1.js → MonacoEditor-D0_QljcX.js} +2 -2
  19. package/dist/MonacoEditor-D0_QljcX.js.map +1 -0
  20. package/dist/{RLSEditor-BM64laoW.js → RLSEditor-D6adwB6e.js} +38 -34
  21. package/dist/RLSEditor-D6adwB6e.js.map +1 -0
  22. package/dist/{SQLEditor-CuAhR-zr.js → SQLEditor-_dCqVAN_.js} +8 -8
  23. package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
  24. package/dist/{SchemaVisualizer-OibKoD3g.js → SchemaVisualizer-C_ErehfH.js} +11 -11
  25. package/dist/SchemaVisualizer-C_ErehfH.js.map +1 -0
  26. package/dist/{StorageView-BMhD29YO.js → StorageView-UvyM2xdW.js} +2 -2
  27. package/dist/StorageView-UvyM2xdW.js.map +1 -0
  28. package/dist/components/AuthSimulationSelector.d.ts +1 -1
  29. package/dist/components/Backups/BackupsView.d.ts +2 -0
  30. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +2 -2
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.es.js +74 -111
  33. package/dist/index.es.js.map +1 -1
  34. package/dist/utils/pgColumnToProperty.d.ts +3 -3
  35. package/dist/utils/sql_utils.d.ts +3 -3
  36. package/package.json +11 -12
  37. package/src/components/ApiExplorer/ApiExplorer.tsx +1 -1
  38. package/src/components/ApiExplorer/TryItPanel.tsx +1 -1
  39. package/src/components/ApiKeys/ApiKeysView.tsx +43 -3
  40. package/src/components/AuthSimulationSelector.tsx +1 -1
  41. package/src/components/Backups/BackupsView.tsx +185 -0
  42. package/src/components/Branches/BranchesView.tsx +1 -1
  43. package/src/components/CronJobs/CronJobsView.tsx +1 -1
  44. package/src/components/JSEditor/JSEditor.tsx +12 -12
  45. package/src/components/JSEditor/JSMonacoEditor.tsx +21 -25
  46. package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
  47. package/src/components/RLSEditor/PolicyEditor.tsx +5 -3
  48. package/src/components/RLSEditor/RLSEditor.tsx +51 -34
  49. package/src/components/RebaseStudio.tsx +12 -3
  50. package/src/components/SQLEditor/MonacoEditor.tsx +1 -1
  51. package/src/components/SQLEditor/SQLEditor.tsx +6 -6
  52. package/src/components/SQLEditor/SQLEditorSidebar.tsx +1 -1
  53. package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
  54. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +10 -9
  55. package/src/components/SchemaVisualizer/TableNode.tsx +1 -1
  56. package/src/components/SchemaVisualizer/useSchemaGraph.ts +11 -11
  57. package/src/components/StorageView/StorageView.tsx +1 -1
  58. package/src/components/StudioHomePage.tsx +62 -87
  59. package/src/index.ts +4 -4
  60. package/src/utils/pgColumnToProperty.ts +3 -3
  61. package/src/utils/sql_utils.test.ts +6 -6
  62. package/src/utils/sql_utils.ts +3 -3
  63. package/dist/ApiExplorer-CdIwR9Ga.js.map +0 -1
  64. package/dist/ApiKeysView-D-_FSlNL.js.map +0 -1
  65. package/dist/AuthSimulationSelector-iEZ-Or_1.js.map +0 -1
  66. package/dist/BranchesView-Dlg78EQ8.js.map +0 -1
  67. package/dist/CronJobsView-4gdtJvoe.js.map +0 -1
  68. package/dist/JSEditor-Ca4XYGRp.js.map +0 -1
  69. package/dist/LogsExplorer-J4xfsuv3.js +0 -206
  70. package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
  71. package/dist/MonacoEditor-COZqrIJ1.js.map +0 -1
  72. package/dist/RLSEditor-BM64laoW.js.map +0 -1
  73. package/dist/SQLEditor-CuAhR-zr.js.map +0 -1
  74. package/dist/SchemaVisualizer-OibKoD3g.js.map +0 -1
  75. package/dist/StorageView-BMhD29YO.js.map +0 -1
  76. package/dist/index.umd.js +0 -11491
  77. package/dist/index.umd.js.map +0 -1
  78. package/src/vite-env.d.ts +0 -1
@@ -1,5 +1,6 @@
1
- import React, { useState, useEffect, useRef, useCallback } from "react";
2
- import { Select, SelectItem, TextField, Checkbox, Label, Typography, cls, defaultBorderMixin } from "@rebasepro/ui";
1
+ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { ArrowDownToLineIcon, Checkbox, cls, defaultBorderMixin, Label, Select, SelectItem, TextField, Typography } from "@rebasepro/ui";
3
+ import { useApiConfig } from "@rebasepro/app";
3
4
 
4
5
  interface LogEntry {
5
6
  id: string;
@@ -25,14 +26,48 @@ const SOURCE_COLORS: Record<string, string> = {
25
26
  system: "text-surface-600 dark:text-surface-400"
26
27
  };
27
28
 
29
+ // Ids are `log_<n>` with a monotonic counter, so numeric comparison tells
30
+ // old entries from new ones across polls.
31
+ const idNum = (entry: LogEntry): number => Number(entry.id.slice("log_".length));
32
+
33
+ // The window is contiguous and ordered, so identical ends mean identical content.
34
+ const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>
35
+ a.length === b.length &&
36
+ a[0]?.id === b[0]?.id &&
37
+ a[a.length - 1]?.id === b[b.length - 1]?.id;
38
+
39
+ // How close to the bottom edge still counts as "at the bottom".
40
+ const STICK_THRESHOLD = 40;
41
+
28
42
  export function LogsExplorer() {
29
43
  const [logs, setLogs] = useState<LogEntry[]>([]);
30
44
  const [level, setLevel] = useState<string>("all");
31
45
  const [source, setSource] = useState<string>("all");
46
+ const [searchInput, setSearchInput] = useState("");
32
47
  const [search, setSearch] = useState("");
33
48
  const [autoScroll, setAutoScroll] = useState(true);
49
+ const [error, setError] = useState<string | null>(null);
50
+ // Entries that arrived while the user was scrolled away from the bottom.
51
+ const [newCount, setNewCount] = useState(0);
52
+ const [atBottom, setAtBottom] = useState(true);
34
53
  const containerRef = useRef<HTMLDivElement>(null);
54
+ // Whether the view is stuck to the bottom right now. A ref, not state:
55
+ // the scroll handler and the fetch loop both read it synchronously.
56
+ const stickRef = useRef(true);
57
+ const lastMaxIdRef = useRef<number | null>(null);
58
+ const apiConfig = useApiConfig();
59
+
60
+ // Debounce the search box so typing doesn't refetch per keystroke.
61
+ useEffect(() => {
62
+ const t = setTimeout(() => setSearch(searchInput), 300);
63
+ return () => clearTimeout(t);
64
+ }, [searchInput]);
65
+
35
66
  const fetchLogs = useCallback(async () => {
67
+ if (!apiConfig?.apiUrl) {
68
+ setError("No API URL configured — cannot load logs.");
69
+ return;
70
+ }
36
71
  try {
37
72
  const params = new URLSearchParams();
38
73
  if (level && level !== "all") params.set("level", level);
@@ -40,14 +75,46 @@ export function LogsExplorer() {
40
75
  if (search) params.set("search", search);
41
76
  params.set("limit", "200");
42
77
 
43
- const resp = await fetch(`/api/logs?${params}`);
44
- if (resp.ok) {
45
- const data: { entries?: LogEntry[] } = await resp.json();
46
- setLogs(data.entries || []);
78
+ // Logs are admin-only, so the request must carry the auth token. The
79
+ // URL is absolute: a relative one would resolve against the frontend
80
+ // origin, which serves index.html rather than the API.
81
+ const headers: Record<string, string> = {};
82
+ const token = apiConfig.getAuthToken ? await apiConfig.getAuthToken() : null;
83
+ if (token) headers["Authorization"] = `Bearer ${token}`;
84
+
85
+ const resp = await fetch(`${apiConfig.apiUrl}/api/logs?${params}`, { headers });
86
+ if (!resp.ok) {
87
+ setError(resp.status === 401 || resp.status === 403
88
+ ? "Not authorised to read logs — an admin role is required."
89
+ : `Could not load logs (HTTP ${resp.status}).`);
90
+ return;
91
+ }
92
+ const data: { entries?: LogEntry[] } = await resp.json();
93
+ // The API returns newest-first; the view tails like a terminal, so
94
+ // flip to chronological order (newest at the bottom).
95
+ const entries = (data.entries || []).slice().reverse();
96
+
97
+ const prevMax = lastMaxIdRef.current;
98
+ if (prevMax != null && !stickRef.current) {
99
+ const fresh = entries.filter(e => idNum(e) > prevMax).length;
100
+ if (fresh > 0) setNewCount(c => c + fresh);
47
101
  }
48
- } catch {
49
- /* ignore poll failures */
102
+ if (entries.length > 0) lastMaxIdRef.current = idNum(entries[entries.length - 1]);
103
+
104
+ // Unchanged window → keep the previous array so React leaves the
105
+ // DOM alone (hover states and text selection survive the poll).
106
+ setLogs(prev => sameLogs(prev, entries) ? prev : entries);
107
+ setError(null);
108
+ } catch (e) {
109
+ setError(e instanceof Error ? e.message : "Could not load logs.");
50
110
  }
111
+ }, [level, source, search, apiConfig]);
112
+
113
+ // A filter change replaces the window wholesale — "new entries since last
114
+ // poll" stops meaning anything, so the counter starts over.
115
+ useEffect(() => {
116
+ lastMaxIdRef.current = null;
117
+ setNewCount(0);
51
118
  }, [level, source, search]);
52
119
 
53
120
  useEffect(() => {
@@ -82,9 +149,31 @@ export function LogsExplorer() {
82
149
  };
83
150
  }, [fetchLogs]);
84
151
 
85
- useEffect(() => {
86
- if (autoScroll && containerRef.current) {
87
- containerRef.current.scrollTop = containerRef.current.scrollHeight;
152
+ const scrollToBottom = useCallback(() => {
153
+ const el = containerRef.current;
154
+ if (!el) return;
155
+ el.scrollTop = el.scrollHeight;
156
+ stickRef.current = true;
157
+ setAtBottom(true);
158
+ setNewCount(0);
159
+ }, []);
160
+
161
+ // Stick to the bottom only while the user is already there. Scrolling up
162
+ // disengages; scrolling back down re-engages. Our own scrollTop writes
163
+ // always land at the bottom, so they can never disengage it.
164
+ const handleScroll = useCallback(() => {
165
+ const el = containerRef.current;
166
+ if (!el) return;
167
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
168
+ stickRef.current = nearBottom;
169
+ setAtBottom(nearBottom);
170
+ if (nearBottom) setNewCount(0);
171
+ }, []);
172
+
173
+ useLayoutEffect(() => {
174
+ if (autoScroll && stickRef.current) {
175
+ const el = containerRef.current;
176
+ if (el) el.scrollTop = el.scrollHeight;
88
177
  }
89
178
  }, [logs, autoScroll]);
90
179
 
@@ -123,15 +212,18 @@ export function LogsExplorer() {
123
212
  <TextField
124
213
  size="small"
125
214
  placeholder="Search logs..."
126
- value={search}
127
- onChange={e => setSearch(e.target.value)}
215
+ value={searchInput}
216
+ onChange={e => setSearchInput(e.target.value)}
128
217
  className="flex-1 min-w-[200px]"
129
218
  />
130
219
  <div className="flex items-center gap-1.5 cursor-pointer ml-2">
131
220
  <Checkbox
132
221
  id="auto-scroll"
133
222
  checked={autoScroll}
134
- onCheckedChange={setAutoScroll}
223
+ onCheckedChange={(checked: boolean) => {
224
+ setAutoScroll(checked);
225
+ if (checked) scrollToBottom();
226
+ }}
135
227
  size="small"
136
228
  padding={false}
137
229
  />
@@ -148,40 +240,70 @@ export function LogsExplorer() {
148
240
  </Typography>
149
241
  </div>
150
242
  </div>
151
-
243
+
244
+ {/* A failing poll must stay visible even while stale logs are on
245
+ screen — otherwise the view quietly freezes. */}
246
+ {error && logs.length > 0 && (
247
+ <div className={cls(
248
+ "px-4 py-1.5 border-b bg-amber-50 dark:bg-amber-950/20 shrink-0",
249
+ defaultBorderMixin
250
+ )}>
251
+ <Typography variant="caption" className="text-amber-700 dark:text-amber-400">
252
+ {error}
253
+ </Typography>
254
+ </div>
255
+ )}
256
+
152
257
  {/* Log entries */}
153
- <div
154
- ref={containerRef}
155
- className="flex-1 overflow-auto py-2"
156
- >
157
- {logs.map(log => (
158
- <div
159
- key={log.id}
160
- className={cls(
161
- "flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
162
- defaultBorderMixin
163
- )}
258
+ <div className="relative flex-1 min-h-0">
259
+ <div
260
+ ref={containerRef}
261
+ onScroll={handleScroll}
262
+ className="h-full overflow-auto py-2"
263
+ >
264
+ {logs.map(log => (
265
+ <div
266
+ key={log.id}
267
+ className={cls(
268
+ "flex gap-4 px-4 py-[6px] border-b hover:bg-surface-100 dark:hover:bg-surface-900 transition-colors",
269
+ defaultBorderMixin
270
+ )}
271
+ >
272
+ <Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
273
+ {new Date(log.timestamp).toLocaleTimeString()}
274
+ </Typography>
275
+ <Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
276
+ {log.level}
277
+ </Typography>
278
+ <Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
279
+ [{log.source}]
280
+ </Typography>
281
+ <Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
282
+ {log.message}
283
+ </Typography>
284
+ </div>
285
+ ))}
286
+ {logs.length === 0 && (
287
+ <div className="p-8 text-center">
288
+ <Typography
289
+ variant="body2"
290
+ className={error ? "text-red-600 dark:text-red-500" : undefined}
291
+ color={error ? undefined : "secondary"}
292
+ >
293
+ {error ?? "No log entries yet. Logs will appear here as requests come in."}
294
+ </Typography>
295
+ </div>
296
+ )}
297
+ </div>
298
+
299
+ {autoScroll && !atBottom && newCount > 0 && (
300
+ <button
301
+ onClick={scrollToBottom}
302
+ className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-full bg-primary text-white text-xs font-medium pl-2.5 pr-3 py-1.5 shadow-md hover:bg-primary-dark transition-colors"
164
303
  >
165
- <Typography variant="body2" color="secondary" className="w-[72px] shrink-0 font-mono">
166
- {new Date(log.timestamp).toLocaleTimeString()}
167
- </Typography>
168
- <Typography variant="body2" className={cls("w-[48px] shrink-0 uppercase font-semibold font-mono", LEVEL_COLORS[log.level] || "text-surface-500")}>
169
- {log.level}
170
- </Typography>
171
- <Typography variant="body2" className={cls("w-[80px] shrink-0 font-mono", SOURCE_COLORS[log.source] || "text-surface-500")}>
172
- [{log.source}]
173
- </Typography>
174
- <Typography variant="body2" className="flex-1 font-mono break-all whitespace-pre-wrap text-surface-900 dark:text-surface-100">
175
- {log.message}
176
- </Typography>
177
- </div>
178
- ))}
179
- {logs.length === 0 && (
180
- <div className="p-8 text-center">
181
- <Typography variant="body2" color="secondary">
182
- No log entries yet. Logs will appear here as requests come in.
183
- </Typography>
184
- </div>
304
+ <ArrowDownToLineIcon size={14}/>
305
+ {newCount} new {newCount === 1 ? "entry" : "entries"}
306
+ </button>
185
307
  )}
186
308
  </div>
187
309
  </div>
@@ -19,7 +19,7 @@ import {
19
19
  TextField,
20
20
  Typography
21
21
  } from "@rebasepro/ui";
22
- import { useTranslation } from "@rebasepro/core";
22
+ import { useTranslation } from "@rebasepro/app";
23
23
  import { MonacoEditor } from "../SQLEditor/MonacoEditor";
24
24
  import { PostgresPolicy } from "./RLSEditor";
25
25
 
@@ -187,7 +187,9 @@ export const PolicyEditor = ({
187
187
 
188
188
  return (
189
189
  <>
190
- <DialogTitle className="flex justify-between items-center w-full" variant="h6">
190
+ {/* Inline header: this editor renders as a full pane, not inside a Dialog,
191
+ so DialogTitle (Radix-bound) cannot be used here. */}
192
+ <Typography variant="h6" gutterBottom className="mt-8 mx-8 flex justify-between items-center w-full">
191
193
  <div>
192
194
  <div>{policy ? t("studio_policy_edit") : t("studio_policy_create")}</div>
193
195
  <div className="text-sm font-normal text-text-secondary dark:text-text-secondary-dark tracking-wide mt-1">
@@ -197,7 +199,7 @@ export const PolicyEditor = ({
197
199
  <IconButton size="small" onClick={() => setHelpOpen(true)}>
198
200
  <HelpCircleIcon size={iconSize.smallest}/>
199
201
  </IconButton>
200
- </DialogTitle>
202
+ </Typography>
201
203
 
202
204
  <DialogContent className="p-4 md:p-6 border-t dark:border-surface-950 bg-surface-50 dark:bg-surface-950" includeMargin={false}>
203
205
  <div className="max-w-4xl mx-auto">
@@ -1,5 +1,5 @@
1
1
 
2
- import { useStudioCollectionRegistry } from "@rebasepro/core";
2
+ import { useStudioCollectionRegistry } from "@rebasepro/app";
3
3
  import React, { useState, useEffect, useCallback, useMemo } from "react";
4
4
  import {
5
5
  Alert,
@@ -25,9 +25,11 @@ import {
25
25
  Trash2Icon,
26
26
  Typography
27
27
  } from "@rebasepro/ui";
28
- import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/core";
29
- import { isPostgresCollection } from "@rebasepro/types";
28
+ import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/app";
29
+ import { isPostgresCollectionConfig } from "@rebasepro/types";
30
30
  import { REBASE_INTERNAL_SCHEMAS, REBASE_INTERNAL_PREFIXES, JUNCTION_TABLES_SQL } from "@rebasepro/common";
31
+ import { getPolicyNamesForRule, getPolicyNamesForRules, getPolicyOperations } from "@rebasepro/utils";
32
+ import { resolveJunctionSpecs, getJunctionSecurityRules } from "@rebasepro/common";
31
33
  import { PolicyEditor } from "./PolicyEditor";
32
34
 
33
35
  type TableCategory = "collection" | "junction" | "internal" | "other";
@@ -387,41 +389,54 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
387
389
  status: "live" };
388
390
  });
389
391
 
390
- // Merge code-based policies
391
- if (activeCollection && isPostgresCollection(activeCollection) && activeCollection.securityRules) {
392
- activeCollection.securityRules.forEach((rule: { name?: string, mode?: string, operation?: string, roles?: string[], using?: string, withCheck?: string }) => {
393
- const ruleName = rule.name;
394
- if (!ruleName) return;
395
-
396
- if (policiesMap[ruleName]) {
397
- // It exists in Postgres, but we have a code definition (potentially edited)
398
- policiesMap[ruleName] = {
399
- policyname: ruleName,
400
- tablename: activeTableData.tableName,
401
- permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
402
- cmd: (rule.operation || "ALL").toUpperCase() as PostgresPolicy["cmd"],
403
- roles: rule.roles || ["public"],
404
- qual: rule.using || null,
405
- with_check: rule.withCheck || null,
406
- status: "both"
407
- };
408
- } else {
409
- policiesMap[ruleName] = {
410
- policyname: ruleName,
392
+ // Merge code-based policies.
393
+ //
394
+ // A rule without an explicit `name` still produces policies Postgres gets
395
+ // `<table>_<op>_<hash>`, one per operation. Skipping those rules left their
396
+ // live policies looking like hand-written SQL ("DB Only"), so derive the
397
+ // names the generator would emit and match on those.
398
+ if (activeCollection && isPostgresCollectionConfig(activeCollection) && activeCollection.securityRules) {
399
+ activeCollection.securityRules.forEach((rule) => {
400
+ const ops = getPolicyOperations(rule);
401
+ const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
402
+
403
+ policyNames.forEach((policyName, opIdx) => {
404
+ policiesMap[policyName] = {
405
+ policyname: policyName,
411
406
  tablename: activeTableData.tableName,
412
407
  permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
413
- cmd: (rule.operation || "ALL").toUpperCase() as PostgresPolicy["cmd"],
414
- roles: rule.roles || ["public"],
408
+ cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
409
+ roles: [...(rule.roles ?? ["public"])],
415
410
  qual: rule.using || null,
416
411
  with_check: rule.withCheck || null,
417
- status: "code_only"
412
+ // "both" = defined in code and live in Postgres (potentially edited)
413
+ status: policiesMap[policyName] ? "both" : "code_only"
418
414
  };
419
- }
415
+ });
420
416
  });
421
417
  }
422
418
 
419
+ // Junction tables have no collection, but their policies are generated
420
+ // too — derived from the endpoints' relations. Recognise them so they
421
+ // don't show as hand-written SQL ("DB Only"). If the registry's
422
+ // collections don't carry resolvable relations, they simply stay "live".
423
+ if (!activeCollection) {
424
+ try {
425
+ const registryCollections = (collectionRegistry.collections ?? []) as unknown as Parameters<typeof resolveJunctionSpecs>[0];
426
+ const spec = resolveJunctionSpecs(registryCollections).get(activeTableData.tableName);
427
+ if (spec) {
428
+ const generatedNames = getPolicyNamesForRules(getJunctionSecurityRules(spec), spec.table);
429
+ for (const p of Object.values(policiesMap)) {
430
+ if (generatedNames.has(p.policyname)) p.status = "both";
431
+ }
432
+ }
433
+ } catch {
434
+ /* serialized configs without relation closures — leave as live */
435
+ }
436
+ }
437
+
423
438
  return Object.values(policiesMap).sort((a, b) => a.policyname.localeCompare(b.policyname));
424
- }, [activeTableData, activeCollection]);
439
+ }, [activeTableData, activeCollection, collectionRegistry.collections]);
425
440
 
426
441
  // Stats for the info tab
427
442
  const rlsStats = useMemo(() => {
@@ -734,7 +749,7 @@ message: e instanceof Error ? e.message : String(e) });
734
749
  roles: newPolicy.roles
735
750
  };
736
751
 
737
- const existingRules = (isPostgresCollection(activeCollection) ? activeCollection.securityRules : undefined) || [];
752
+ const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
738
753
  let newRules;
739
754
  if (editingPolicy === "new") {
740
755
  newRules = [...existingRules, rule];
@@ -829,8 +844,10 @@ message: e instanceof Error ? e.message : String(e) });
829
844
  </Typography>
830
845
  <Typography variant="caption" className="opacity-80">
831
846
  This is an auto-generated junction table for a many-to-many relation.
832
- Its access is typically managed through the related collections' policies.
833
- You can still add RLS policies directly if needed.
847
+ Rebase derives its policies: rows are readable when both related rows
848
+ are, and writable following the declaring collection&apos;s update rules
849
+ (plus the server/admin baseline). You can still add RLS policies
850
+ directly to broaden access.
834
851
  </Typography>
835
852
  </div>
836
853
  </div>
@@ -845,7 +862,7 @@ message: e instanceof Error ? e.message : String(e) });
845
862
  Unmapped Table
846
863
  </Typography>
847
864
  <Typography variant="caption" className="opacity-80">
848
- This table exists in the database but isn't mapped to a collection definition.
865
+ This table exists in the database but isn&apos;t mapped to a collection definition.
849
866
  Import it into a Schema configuration file to manage security policies visually.
850
867
  </Typography>
851
868
  </div>
@@ -926,7 +943,7 @@ message: e instanceof Error ? e.message : String(e) });
926
943
  roles: policy.roles
927
944
  };
928
945
 
929
- const existingRules = (isPostgresCollection(activeCollection) ? activeCollection.securityRules : undefined) || [];
946
+ const existingRules = (isPostgresCollectionConfig(activeCollection) ? activeCollection.securityRules : undefined) || [];
930
947
  const newRules = [...existingRules, rule];
931
948
 
932
949
  try {
@@ -1,5 +1,5 @@
1
1
  import React, { lazy, Suspense, useLayoutEffect, useMemo } from "react";
2
- import { useRebaseRegistryDispatch } from "@rebasepro/core";
2
+ import { useRebaseRegistryDispatch } from "@rebasepro/app";
3
3
  import type { RebaseStudioConfig, AppView } from "@rebasepro/types";
4
4
  import { CircularProgressCenter } from "@rebasepro/ui";
5
5
 
@@ -12,6 +12,7 @@ const StorageView = lazy(() => import("./StorageView/StorageView").then(m => ({
12
12
  const CronJobsView = lazy(() => import("./CronJobs/CronJobsView").then(m => ({ default: m.CronJobsView })));
13
13
  const SchemaVisualizer = lazy(() => import("./SchemaVisualizer/SchemaVisualizer").then(m => ({ default: m.SchemaVisualizer })));
14
14
  const BranchesView = lazy(() => import("./Branches/BranchesView").then(m => ({ default: m.BranchesView })));
15
+ const BackupsView = lazy(() => import("./Backups/BackupsView").then(m => ({ default: m.BackupsView })));
15
16
  const ApiExplorer = lazy(() => import("./ApiExplorer/ApiExplorer").then(m => ({ default: m.ApiExplorer })));
16
17
  const LogsExplorer = lazy(() => import("./LogsExplorer/LogsExplorer").then(m => ({ default: m.LogsExplorer })));
17
18
  const ApiKeysView = lazy(() => import("./ApiKeys/ApiKeysView").then(m => ({ default: m.ApiKeysView })));
@@ -23,7 +24,7 @@ import { StudioHomePage } from "./StudioHomePage";
23
24
  * Renders nothing — purely registers config into the RebaseRegistry.
24
25
  *
25
26
  * The "schema" tool (collection editor view) is now a built-in CMS feature.
26
- * When `<RebaseCMS collectionEditor={...}>` is used, the schema view is
27
+ * When `<RebaseAdmin collectionEditor={...}>` is used, the schema view is
27
28
  * automatically injected into Studio — no manual wiring needed.
28
29
  */
29
30
  const DEFAULT_HOME_PAGE = <StudioHomePage/>;
@@ -35,7 +36,7 @@ export function RebaseStudio({ tools, homePage }: RebaseStudioConfig) {
35
36
 
36
37
  const devViews: AppView[] = useMemo(() => {
37
38
  const views: AppView[] = [];
38
- const activeTools = tools ?? ["sql", "js", "rls", "storage", "cron", "schema-visualizer", "branches", "api", "logs", "api-keys"];
39
+ const activeTools = tools ?? ["sql", "js", "rls", "storage", "cron", "schema-visualizer", "branches", "backups", "api", "logs", "api-keys"];
39
40
  const suspense = (el: React.ReactNode) => <Suspense fallback={<CircularProgressCenter/>}>{el}</Suspense>;
40
41
 
41
42
  if (activeTools.includes("sql")) {
@@ -93,6 +94,14 @@ group: "Database",
93
94
  icon: "GitBranch",
94
95
  description: "Create and manage database branches",
95
96
  view: suspense(<BranchesView/>) });
97
+ }
98
+ if (activeTools.includes("backups")) {
99
+ views.push({ slug: "backups",
100
+ name: "Backups",
101
+ group: "Database",
102
+ icon: "Database",
103
+ description: "Download database backups",
104
+ view: suspense(<BackupsView/>) });
96
105
  }
97
106
  if (activeTools.includes("api")) {
98
107
  views.push({ slug: "api",
@@ -2,7 +2,7 @@ import React, { useRef } from "react";
2
2
  import Editor, { Monaco, OnMount } from "@monaco-editor/react";
3
3
  import type { editor, Position, IRange } from "monaco-editor";
4
4
  import { cls, defaultBorderMixin } from "@rebasepro/ui";
5
- import { useModeController } from "@rebasepro/core";
5
+ import { useModeController } from "@rebasepro/app";
6
6
 
7
7
  export type MonacoEditorProps = {
8
8
  value: string;
@@ -1,6 +1,6 @@
1
1
 
2
- import { IconForView } from "@rebasepro/core";
3
- import { useStudioCollectionRegistry, useStudioSideEntityController } from "@rebasepro/core";
2
+ import { IconForView } from "@rebasepro/app";
3
+ import { useStudioCollectionRegistry, useStudioSidePanelController } from "@rebasepro/app";
4
4
  import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
  import {
@@ -42,7 +42,7 @@ import {
42
42
  XIcon
43
43
  } from "@rebasepro/ui";
44
44
 
45
- import { useRebaseContext, useSnackbarController, ConfirmationDialog, ErrorView, useTranslation } from "@rebasepro/core";
45
+ import { useRebaseContext, useSnackbarController, ConfirmationDialog, ErrorView, useTranslation } from "@rebasepro/app";
46
46
  import { MonacoEditor } from "./MonacoEditor";
47
47
  import { SQLEditorSidebar, Snippet } from "./SQLEditorSidebar";
48
48
  import { parseFirst } from "pgsql-ast-parser";
@@ -213,7 +213,7 @@ const getStoragePrefix = (baseUrl?: string) => {
213
213
 
214
214
  export const SQLEditor = () => {
215
215
  const { databaseAdmin, client } = useRebaseContext();
216
- const sideEntityController = useStudioSideEntityController();
216
+ const sidePanelController = useStudioSidePanelController();
217
217
  const snackbarController = useSnackbarController();
218
218
  const collectionRegistry = useStudioCollectionRegistry();
219
219
 
@@ -1100,7 +1100,7 @@ id: String(ra.entityId) })}>
1100
1100
  className="text-surface-400 dark:text-surface-500 hover:text-surface-600 dark:hover:text-surface-300 transition-colors"
1101
1101
  onClick={(e) => {
1102
1102
  e.stopPropagation();
1103
- sideEntityController?.open({
1103
+ sidePanelController?.open({
1104
1104
  path: ra.collection.collection.slug,
1105
1105
  entityId: ra.entityId,
1106
1106
  collection: ra.collection.collection,
@@ -1133,7 +1133,7 @@ id: String(ra.entityId) })}>
1133
1133
  key={ra.collection.tableName}
1134
1134
  dense
1135
1135
  onClick={() => {
1136
- sideEntityController?.open({
1136
+ sidePanelController?.open({
1137
1137
  path: ra.collection.collection.slug,
1138
1138
  entityId: ra.entityId,
1139
1139
  collection: ra.collection.collection,
@@ -1,7 +1,7 @@
1
1
 
2
2
  import React, { useState } from "react";
3
3
  import { cls, defaultBorderMixin, IconButton, iconSize, Tab, Tabs, Trash2Icon, Typography } from "@rebasepro/ui";
4
- import { useTranslation } from "@rebasepro/core";
4
+ import { useTranslation } from "@rebasepro/app";
5
5
  import { SchemaBrowser } from "./SchemaBrowser";
6
6
  import { TableInfo } from "./SQLEditor";
7
7
 
@@ -15,7 +15,7 @@ import {
15
15
  Typography
16
16
  } from "@rebasepro/ui";
17
17
  import { TableInfo } from "./SQLEditor";
18
- import { ErrorView, useTranslation } from "@rebasepro/core";
18
+ import { ErrorView, useTranslation } from "@rebasepro/app";
19
19
 
20
20
  export const SchemaBrowser = ({
21
21
  onTableClick,
@@ -86,7 +86,7 @@ export const SchemaBrowser = ({
86
86
  e.stopPropagation();
87
87
  navigator.clipboard.writeText(table.tableName);
88
88
  }}
89
- title="CopyIcon table name"
89
+ title="Copy table name"
90
90
  >
91
91
  <CopyIcon size={iconSize.small}/>
92
92
  </IconButton>
@@ -148,7 +148,7 @@ export const SchemaBrowser = ({
148
148
  e.stopPropagation();
149
149
  navigator.clipboard.writeText(col.name);
150
150
  }}
151
- title="CopyIcon column name"
151
+ title="Copy column name"
152
152
  >
153
153
  <CopyIcon size={iconSize.smallest}/>
154
154
  </IconButton>