@rebasepro/studio 0.9.1-canary.fd3754b → 0.10.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 (41) hide show
  1. package/dist/{ApiKeysView-Dbe5cXkS.js → ApiKeysView-vQKqt2wl.js} +44 -2
  2. package/dist/ApiKeysView-vQKqt2wl.js.map +1 -0
  3. package/dist/{JSEditor-Etgw9-Cw.js → JSEditor-BeHHuxhA.js} +3 -3
  4. package/dist/{JSEditor-Etgw9-Cw.js.map → JSEditor-BeHHuxhA.js.map} +1 -1
  5. package/dist/LogsExplorer-BCXoGREr.js +298 -0
  6. package/dist/LogsExplorer-BCXoGREr.js.map +1 -0
  7. package/dist/{RLSEditor-Cgv_kUVW.js → RLSEditor-rLlMcZPG.js} +42 -28
  8. package/dist/RLSEditor-rLlMcZPG.js.map +1 -0
  9. package/dist/{SQLEditor-ZZsy1Mk6.js → SQLEditor-_dCqVAN_.js} +3 -3
  10. package/dist/SQLEditor-_dCqVAN_.js.map +1 -0
  11. package/dist/{SchemaVisualizer-D01DzU-f.js → SchemaVisualizer-C_ErehfH.js} +2 -2
  12. package/dist/{SchemaVisualizer-D01DzU-f.js.map → SchemaVisualizer-C_ErehfH.js.map} +1 -1
  13. package/dist/components/RLSEditor/PolicyEditor.d.ts +1 -1
  14. package/dist/components/SQLEditor/SQLEditor.d.ts +2 -10
  15. package/dist/components/SQLEditor/SQLEditorSidebar.d.ts +1 -1
  16. package/dist/components/SQLEditor/SchemaBrowser.d.ts +1 -1
  17. package/dist/components/SQLEditor/sql_editor_types.d.ts +16 -0
  18. package/dist/index.es.js +58 -105
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/utils/sql_utils.d.ts +1 -1
  21. package/package.json +13 -12
  22. package/src/components/ApiKeys/ApiKeysView.tsx +41 -1
  23. package/src/components/JSEditor/JSEditor.tsx +3 -3
  24. package/src/components/LogsExplorer/LogsExplorer.tsx +168 -46
  25. package/src/components/RLSEditor/PolicyEditor.tsx +15 -13
  26. package/src/components/RLSEditor/RLSEditor.tsx +48 -18
  27. package/src/components/SQLEditor/SQLEditor.tsx +3 -11
  28. package/src/components/SQLEditor/SQLEditorSidebar.tsx +1 -1
  29. package/src/components/SQLEditor/SchemaBrowser.tsx +3 -3
  30. package/src/components/SQLEditor/sql_editor_types.ts +18 -0
  31. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +2 -1
  32. package/src/components/StudioHomePage.tsx +61 -86
  33. package/src/utils/sql_utils.test.ts +1 -1
  34. package/src/utils/sql_utils.ts +1 -1
  35. package/dist/ApiKeysView-Dbe5cXkS.js.map +0 -1
  36. package/dist/LogsExplorer-J4xfsuv3.js +0 -206
  37. package/dist/LogsExplorer-J4xfsuv3.js.map +0 -1
  38. package/dist/RLSEditor-Cgv_kUVW.js.map +0 -1
  39. package/dist/SQLEditor-ZZsy1Mk6.js.map +0 -1
  40. package/dist/index.umd.js +0 -11709
  41. package/dist/index.umd.js.map +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>
@@ -21,7 +21,7 @@ import {
21
21
  } from "@rebasepro/ui";
22
22
  import { useTranslation } from "@rebasepro/app";
23
23
  import { MonacoEditor } from "../SQLEditor/MonacoEditor";
24
- import { PostgresPolicy } from "./RLSEditor";
24
+ import type { PostgresPolicy } from "./RLSEditor";
25
25
 
26
26
  export interface PolicyEditorProps {
27
27
  policy?: PostgresPolicy;
@@ -84,34 +84,34 @@ const POLICY_PRESETS: PolicyPreset[] = [
84
84
  {
85
85
  id: "user_select_own",
86
86
  label: "Users can read their own rows",
87
- description: "Users can only read rows where the user_id matches their auth.uid()",
87
+ description: "Users can only read rows where the uid matches their auth.uid()",
88
88
  policyname: "Users can select their own data",
89
89
  cmd: "SELECT",
90
90
  permissive: "PERMISSIVE",
91
91
  roles: ["authenticated"],
92
- qual: "auth.uid() = user_id",
92
+ qual: "auth.uid() = uid",
93
93
  with_check: ""
94
94
  },
95
95
  {
96
96
  id: "user_update_own",
97
97
  label: "Users can update their own rows",
98
- description: "Users can only update rows where the user_id matches their auth.uid()",
98
+ description: "Users can only update rows where the uid matches their auth.uid()",
99
99
  policyname: "Users can update their own data",
100
100
  cmd: "UPDATE",
101
101
  permissive: "PERMISSIVE",
102
102
  roles: ["authenticated"],
103
- qual: "auth.uid() = user_id",
104
- with_check: "auth.uid() = user_id"
103
+ qual: "auth.uid() = uid",
104
+ with_check: "auth.uid() = uid"
105
105
  },
106
106
  {
107
107
  id: "user_delete_own",
108
108
  label: "Users can delete their own rows",
109
- description: "Users can only delete rows where the user_id matches their auth.uid()",
109
+ description: "Users can only delete rows where the uid matches their auth.uid()",
110
110
  policyname: "Users can delete their own data",
111
111
  cmd: "DELETE",
112
112
  permissive: "PERMISSIVE",
113
113
  roles: ["authenticated"],
114
- qual: "auth.uid() = user_id",
114
+ qual: "auth.uid() = uid",
115
115
  with_check: ""
116
116
  }
117
117
  ];
@@ -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">
@@ -393,7 +395,7 @@ export const PolicyEditor = ({
393
395
  {t("studio_policy_help_step3_desc")}
394
396
  </Typography>
395
397
  <div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
396
- Example: auth.uid() = user_id
398
+ Example: auth.uid() = uid
397
399
  </div>
398
400
  <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
399
401
  {t("studio_policy_help_step3_example")}
@@ -406,7 +408,7 @@ export const PolicyEditor = ({
406
408
  {t("studio_policy_help_step4_desc")}
407
409
  </Typography>
408
410
  <div className={cls("bg-surface-100 dark:bg-surface-950 px-3 py-2 rounded-md font-mono text-sm my-2", defaultBorderMixin)}>
409
- Example: auth.uid() = user_id
411
+ Example: auth.uid() = uid
410
412
  </div>
411
413
  <Typography variant="caption" className="text-text-secondary dark:text-text-secondary-dark">
412
414
  {t("studio_policy_help_step4_example")}
@@ -421,7 +423,7 @@ export const PolicyEditor = ({
421
423
  <ul className="list-disc pl-5 space-y-2 text-sm text-text-secondary dark:text-text-secondary-dark font-normal">
422
424
  <li>
423
425
  <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.uid()</code>
424
- <span className="block mt-0.5">Returns the current user&apos;s ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.uid() = user_id</code></span>
426
+ <span className="block mt-0.5">Returns the current user&apos;s ID as text. Example: <code className="bg-surface-100 dark:bg-surface-950 px-1 py-0.5 rounded text-[11px]">auth.uid() = uid</code></span>
425
427
  </li>
426
428
  <li>
427
429
  <code className="bg-surface-100 dark:bg-surface-950 px-1.5 py-0.5 rounded mr-1 whitespace-nowrap">auth.jwt()</code>
@@ -28,6 +28,8 @@ import {
28
28
  import { useRebaseContext, useSnackbarController, ErrorView, useTranslation } from "@rebasepro/app";
29
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,28 +389,54 @@ export const RLSEditor = ({ apiUrl = "" }: { apiUrl?: string }) => {
387
389
  status: "live" };
388
390
  });
389
391
 
390
- // Merge code-based policies
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.
391
398
  if (activeCollection && isPostgresCollectionConfig(activeCollection) && activeCollection.securityRules) {
392
399
  activeCollection.securityRules.forEach((rule) => {
393
- const ruleName = rule.name;
394
- if (!ruleName) return;
395
-
396
- policiesMap[ruleName] = {
397
- policyname: ruleName,
398
- tablename: activeTableData.tableName,
399
- permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
400
- cmd: (rule.operation || "ALL").toUpperCase() as PostgresPolicy["cmd"],
401
- roles: [...(rule.roles ?? ["public"])],
402
- qual: rule.using || null,
403
- with_check: rule.withCheck || null,
404
- // "both" = defined in code and live in Postgres (potentially edited)
405
- status: policiesMap[ruleName] ? "both" : "code_only"
406
- };
400
+ const ops = getPolicyOperations(rule);
401
+ const policyNames = getPolicyNamesForRule(rule, activeTableData.tableName);
402
+
403
+ policyNames.forEach((policyName, opIdx) => {
404
+ policiesMap[policyName] = {
405
+ policyname: policyName,
406
+ tablename: activeTableData.tableName,
407
+ permissive: (rule.mode || "permissive").toUpperCase() as PostgresPolicy["permissive"],
408
+ cmd: (ops[opIdx] ?? rule.operation ?? "ALL").toUpperCase() as PostgresPolicy["cmd"],
409
+ roles: [...(rule.roles ?? ["public"])],
410
+ qual: rule.using || null,
411
+ with_check: rule.withCheck || null,
412
+ // "both" = defined in code and live in Postgres (potentially edited)
413
+ status: policiesMap[policyName] ? "both" : "code_only"
414
+ };
415
+ });
407
416
  });
408
417
  }
409
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
+
410
438
  return Object.values(policiesMap).sort((a, b) => a.policyname.localeCompare(b.policyname));
411
- }, [activeTableData, activeCollection]);
439
+ }, [activeTableData, activeCollection, collectionRegistry.collections]);
412
440
 
413
441
  // Stats for the info tab
414
442
  const rlsStats = useMemo(() => {
@@ -816,8 +844,10 @@ message: e instanceof Error ? e.message : String(e) });
816
844
  </Typography>
817
845
  <Typography variant="caption" className="opacity-80">
818
846
  This is an auto-generated junction table for a many-to-many relation.
819
- Its access is typically managed through the related collections&apos; policies.
820
- 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.
821
851
  </Typography>
822
852
  </div>
823
853
  </div>
@@ -49,17 +49,9 @@ import { parseFirst } from "pgsql-ast-parser";
49
49
  import { determineTableAndPK, resolveQueryCollections, ResolvedQueryCollection } from "../../utils/sql_utils";
50
50
  import { ExplainVisualizer } from "./ExplainVisualizer";
51
51
 
52
- export interface SQLEditorColumnInfo {
53
- name: string;
54
- dataType: string;
55
- isPrimaryKey: boolean;
56
- }
57
-
58
- export interface TableInfo {
59
- schemaName: string;
60
- tableName: string;
61
- columns: SQLEditorColumnInfo[];
62
- }
52
+ import type { SQLEditorColumnInfo, TableInfo } from "./sql_editor_types";
53
+
54
+ export type { SQLEditorColumnInfo, TableInfo };
63
55
 
64
56
  const QueryLoadingView = () => {
65
57
  const [elapsed, setElapsed] = useState(0);
@@ -3,7 +3,7 @@ import React, { useState } from "react";
3
3
  import { cls, defaultBorderMixin, IconButton, iconSize, Tab, Tabs, Trash2Icon, Typography } from "@rebasepro/ui";
4
4
  import { useTranslation } from "@rebasepro/app";
5
5
  import { SchemaBrowser } from "./SchemaBrowser";
6
- import { TableInfo } from "./SQLEditor";
6
+ import type { TableInfo } from "./sql_editor_types";
7
7
 
8
8
  export interface Snippet {
9
9
  id: string;
@@ -14,7 +14,7 @@ import {
14
14
  RefreshCwIcon,
15
15
  Typography
16
16
  } from "@rebasepro/ui";
17
- import { TableInfo } from "./SQLEditor";
17
+ import type { TableInfo } from "./sql_editor_types";
18
18
  import { ErrorView, useTranslation } from "@rebasepro/app";
19
19
 
20
20
  export const SchemaBrowser = ({
@@ -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>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Shapes describing the introspected schema the SQL editor works against.
3
+ * They live here rather than in SQLEditor.tsx because the sidebar, the schema
4
+ * browser and utils/sql_utils all need them, and importing them from the
5
+ * component would close a cycle back through it.
6
+ */
7
+
8
+ export interface SQLEditorColumnInfo {
9
+ name: string;
10
+ dataType: string;
11
+ isPrimaryKey: boolean;
12
+ }
13
+
14
+ export interface TableInfo {
15
+ schemaName: string;
16
+ tableName: string;
17
+ columns: SQLEditorColumnInfo[];
18
+ }
@@ -543,7 +543,8 @@ duration: 400 }
543
543
  />
544
544
  <Controls
545
545
  showInteractive={false}
546
- className="!bg-white dark:!bg-surface-900 !border !border-surface-200/40 dark:!border-surface-700/40 !shadow-sm !rounded-lg"
546
+ // mb clears the legend overlay pinned at bottom-left
547
+ className="!mb-16 !bg-white dark:!bg-surface-900 !border !border-surface-200/40 dark:!border-surface-700/40 !shadow-sm !rounded-lg dark:[--xy-controls-button-background-color:var(--color-surface-900)] dark:[--xy-controls-button-background-color-hover:var(--color-surface-800)] dark:[--xy-controls-button-color:var(--color-surface-300)] dark:[--xy-controls-button-color-hover:var(--color-surface-50)] dark:[--xy-controls-button-border-color:var(--color-surface-700)]"
547
548
  />
548
549
  <MiniMap
549
550
  nodeStrokeColor={(n) => {