@rebasepro/studio 0.17.3 → 0.18.1

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 (81) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +12 -4
  3. package/dist/{ApiKeysView-DiCurTEU.js → ApiKeysView-EIvegrUk.js} +54 -39
  4. package/dist/ApiKeysView-EIvegrUk.js.map +1 -0
  5. package/dist/{BackupsView-DNS6LdVg.js → BackupsView-DVkKzaPR.js} +29 -31
  6. package/dist/BackupsView-DVkKzaPR.js.map +1 -0
  7. package/dist/{BranchesView-CM3B-ER3.js → BranchesView-Df8hQ8wE.js} +85 -24
  8. package/dist/BranchesView-Df8hQ8wE.js.map +1 -0
  9. package/dist/{CronJobsView-BHtJZeJZ.js → CronJobsView-CLOGjb-L.js} +32 -27
  10. package/dist/CronJobsView-CLOGjb-L.js.map +1 -0
  11. package/dist/{RLSEditor-CBX4uD66.js → RLSEditor-Sa1D34ny.js} +724 -615
  12. package/dist/RLSEditor-Sa1D34ny.js.map +1 -0
  13. package/dist/{SQLEditor-ByecwX8B.js → SQLEditor-BfNA8xM3.js} +9 -4
  14. package/dist/{SQLEditor-ByecwX8B.js.map → SQLEditor-BfNA8xM3.js.map} +1 -1
  15. package/dist/{SchemaVisualizer-D6tMZgeC.js → SchemaVisualizer-DTDMYL6o.js} +84 -110
  16. package/dist/SchemaVisualizer-DTDMYL6o.js.map +1 -0
  17. package/dist/{StorageView-BGDeC5GI.js → StorageView-Ed9Q_VlB.js} +5 -32
  18. package/dist/StorageView-Ed9Q_VlB.js.map +1 -0
  19. package/dist/components/RLSEditor/saveRules.d.ts +38 -0
  20. package/dist/components/RebaseStudio.d.ts +1 -1
  21. package/dist/components/SchemaVisualizer/schema-visualizer.utils.d.ts +5 -2
  22. package/dist/components/SchemaVisualizer/useSchemaGraph.d.ts +0 -4
  23. package/dist/components/load-failure-view.d.ts +17 -0
  24. package/dist/components/load-failure.d.ts +47 -0
  25. package/dist/index.es.js +56 -36
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/load-failure-C6OMJSGC.js +31 -0
  28. package/dist/load-failure-C6OMJSGC.js.map +1 -0
  29. package/dist/load-failure-view-CBTB_h-h.js +48 -0
  30. package/dist/load-failure-view-CBTB_h-h.js.map +1 -0
  31. package/package.json +36 -21
  32. package/dist/ApiKeysView-DiCurTEU.js.map +0 -1
  33. package/dist/BackupsView-DNS6LdVg.js.map +0 -1
  34. package/dist/BranchesView-CM3B-ER3.js.map +0 -1
  35. package/dist/CronJobsView-BHtJZeJZ.js.map +0 -1
  36. package/dist/RLSEditor-CBX4uD66.js.map +0 -1
  37. package/dist/SchemaVisualizer-D6tMZgeC.js.map +0 -1
  38. package/dist/StorageView-BGDeC5GI.js.map +0 -1
  39. package/dist/components/StorageView/storage-failure.d.ts +0 -34
  40. package/src/components/ApiExplorer/ApiExplorer.tsx +0 -299
  41. package/src/components/ApiExplorer/EndpointDetail.tsx +0 -279
  42. package/src/components/ApiExplorer/TryItPanel.tsx +0 -499
  43. package/src/components/ApiExplorer/parseSpec.ts +0 -104
  44. package/src/components/ApiExplorer/types.ts +0 -84
  45. package/src/components/ApiKeys/ApiKeysView.tsx +0 -479
  46. package/src/components/ApiKeys/CreateApiKeyDialog.tsx +0 -711
  47. package/src/components/ApiKeys/permissions.ts +0 -145
  48. package/src/components/AuthSimulationSelector.tsx +0 -72
  49. package/src/components/Backups/BackupsView.tsx +0 -185
  50. package/src/components/Branches/BranchesView.tsx +0 -386
  51. package/src/components/CronJobs/CronJobsView.tsx +0 -413
  52. package/src/components/JSEditor/JSEditor.tsx +0 -1100
  53. package/src/components/JSEditor/JSEditorSidebar.tsx +0 -350
  54. package/src/components/JSEditor/JSMonacoEditor.tsx +0 -410
  55. package/src/components/LogsExplorer/LogsExplorer.tsx +0 -273
  56. package/src/components/LogsExplorer/useLogTail.ts +0 -356
  57. package/src/components/RLSEditor/PolicyEditor.tsx +0 -399
  58. package/src/components/RLSEditor/RLSEditor.tsx +0 -1128
  59. package/src/components/RLSEditor/index.ts +0 -1
  60. package/src/components/RLSEditor/policy-presets.ts +0 -176
  61. package/src/components/RLSEditor/saveSecurityRules.ts +0 -48
  62. package/src/components/RebaseStudio.tsx +0 -174
  63. package/src/components/SQLEditor/ExplainVisualizer.tsx +0 -128
  64. package/src/components/SQLEditor/MonacoEditor.tsx +0 -203
  65. package/src/components/SQLEditor/SQLEditor.tsx +0 -1518
  66. package/src/components/SQLEditor/SQLEditorSidebar.tsx +0 -173
  67. package/src/components/SQLEditor/SchemaBrowser.tsx +0 -168
  68. package/src/components/SQLEditor/sql_editor_types.ts +0 -18
  69. package/src/components/SchemaVisualizer/RelationEdge.tsx +0 -102
  70. package/src/components/SchemaVisualizer/SchemaVisualizer.tsx +0 -710
  71. package/src/components/SchemaVisualizer/TableNode.tsx +0 -257
  72. package/src/components/SchemaVisualizer/schema-visualizer.utils.ts +0 -140
  73. package/src/components/SchemaVisualizer/useLiveRls.ts +0 -86
  74. package/src/components/SchemaVisualizer/useSchemaGraph.ts +0 -426
  75. package/src/components/StorageView/StorageView.tsx +0 -1588
  76. package/src/components/StorageView/storage-failure.ts +0 -63
  77. package/src/components/StudioHomePage.tsx +0 -430
  78. package/src/index.ts +0 -31
  79. package/src/utils/parseSpec.test.ts +0 -309
  80. package/src/utils/sql_utils.test.ts +0 -265
  81. package/src/utils/sql_utils.ts +0 -291
@@ -1,356 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import { useApiBase, useApiConfig } from "@rebasepro/app";
3
-
4
- export interface LogEntry {
5
- id: string;
6
- timestamp: string;
7
- level: "debug" | "info" | "warn" | "error";
8
- source: "api" | "auth" | "storage" | "realtime" | "system";
9
- message: string;
10
- metadata?: Record<string, unknown>;
11
- }
12
-
13
- export interface LogTailFilters {
14
- /** `"all"` and `undefined` both mean unfiltered. */
15
- level?: string;
16
- source?: string;
17
- search?: string;
18
- }
19
-
20
- /**
21
- * How the view is currently being fed.
22
- *
23
- * Surfaced rather than kept private: "connecting" and "polling" look identical
24
- * from the outside until something is wrong, and the first question about a log
25
- * view that seems stuck is whether it is actually live.
26
- */
27
- export type LogTransport = "connecting" | "live" | "polling";
28
-
29
- /**
30
- * Entries kept in the browser.
31
- *
32
- * The server window is 200; a tail that never forgets would grow without bound
33
- * on a busy backend, since nothing else here ever drops an entry.
34
- */
35
- const MAX_ENTRIES = 1000;
36
-
37
- /** Fallback cadence, used only against a server with no stream route. */
38
- const POLL_MS = 3000;
39
-
40
- const RETRY_BASE_MS = 1000;
41
- const RETRY_MAX_MS = 15_000;
42
-
43
- /** Ids are `log_<n>` off a monotonic counter, so numeric order is push order. */
44
- export const idNum = (entry: LogEntry): number => Number(entry.id.slice("log_".length));
45
-
46
- /** The window is contiguous and ordered, so identical ends mean identical content. */
47
- export const sameLogs = (a: LogEntry[], b: LogEntry[]): boolean =>
48
- a.length === b.length &&
49
- a[0]?.id === b[0]?.id &&
50
- a[a.length - 1]?.id === b[b.length - 1]?.id;
51
-
52
- /** One identity for "nothing", so clearing an already-empty view is not a change. */
53
- const EMPTY: LogEntry[] = [];
54
-
55
- const capped = (entries: LogEntry[]): LogEntry[] =>
56
- entries.length > MAX_ENTRIES ? entries.slice(entries.length - MAX_ENTRIES) : entries;
57
-
58
- export interface SSEFrame {
59
- event: string;
60
- data: string;
61
- }
62
-
63
- /**
64
- * Split an SSE byte stream into frames.
65
- *
66
- * `EventSource` would do this, and cannot be used: logs are admin-only, and
67
- * `EventSource` has no way to send an `Authorization` header. So the stream is
68
- * read off `fetch` and framed here.
69
- *
70
- * Exported for its own test — the parser is the part with edge cases (a frame
71
- * split across two chunks, multi-line data, comment keepalives).
72
- */
73
- export async function* readSSEFrames(
74
- body: ReadableStream<Uint8Array>
75
- ): AsyncGenerator<SSEFrame> {
76
- const reader = body.getReader();
77
- const decoder = new TextDecoder();
78
- let buffer = "";
79
-
80
- while (true) {
81
- const { done, value } = await reader.read();
82
- if (done) break;
83
- // `stream: true` keeps a multi-byte character split across two chunks
84
- // from decoding as two replacement characters.
85
- buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
86
-
87
- let boundary = buffer.indexOf("\n\n");
88
- while (boundary !== -1) {
89
- const raw = buffer.slice(0, boundary);
90
- buffer = buffer.slice(boundary + 2);
91
- const frame = parseFrame(raw);
92
- if (frame) yield frame;
93
- boundary = buffer.indexOf("\n\n");
94
- }
95
- }
96
- }
97
-
98
- function parseFrame(raw: string): SSEFrame | null {
99
- let event = "message";
100
- const data: string[] = [];
101
- for (const line of raw.split("\n")) {
102
- // A line starting with ":" is a comment. The server sends them as
103
- // keepalives, and they carry nothing.
104
- if (!line || line.startsWith(":")) continue;
105
- const colon = line.indexOf(":");
106
- const field = colon === -1 ? line : line.slice(0, colon);
107
- const value = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
108
- if (field === "event") event = value;
109
- else if (field === "data") data.push(value);
110
- }
111
- if (data.length === 0) return null;
112
- return { event,
113
- data: data.join("\n") };
114
- }
115
-
116
- const buildParams = (filters: LogTailFilters, limit: number): string => {
117
- const params = new URLSearchParams();
118
- if (filters.level && filters.level !== "all") params.set("level", filters.level);
119
- if (filters.source && filters.source !== "all") params.set("source", filters.source);
120
- if (filters.search) params.set("search", filters.search);
121
- params.set("limit", String(limit));
122
- return params.toString();
123
- };
124
-
125
- /**
126
- * Why the log is not being served, in the server's words where it has any.
127
- *
128
- * The admin gate answers 501 with a message naming the setting to change (a
129
- * backend with no `jwtSecret` and no auth adapter cannot tell an admin from the
130
- * internet, so it refuses rather than opens). Rendering "HTTP 501" over the top
131
- * of that would throw away the only sentence that says what to do.
132
- */
133
- async function failureMessage(resp: { status: number; json?: () => Promise<unknown> }): Promise<string> {
134
- try {
135
- const body = await resp.json?.() as { error?: { message?: string } } | undefined;
136
- const message = body?.error?.message;
137
- if (typeof message === "string" && message) return message;
138
- } catch {
139
- /* not every failure has a JSON body */
140
- }
141
- return resp.status === 401 || resp.status === 403
142
- ? "Not authorised to read logs — an admin role is required."
143
- : `Could not load logs (HTTP ${resp.status}).`;
144
- }
145
-
146
- export interface LogTail {
147
- logs: LogEntry[];
148
- error: string | null;
149
- transport: LogTransport;
150
- /**
151
- * Entries the server had to discard because this client was not draining the
152
- * stream fast enough, since the connection opened.
153
- *
154
- * Surfaced rather than swallowed. The server bounds what it will hold per
155
- * connection, so under a heavy enough burst there is a hole in the tail — and
156
- * a tail with a hole in it that says so beats one that silently lies.
157
- */
158
- dropped: number;
159
- }
160
-
161
- /**
162
- * Tail the server's log buffer.
163
- *
164
- * Server-sent events, with the 3s poll kept only as a fallback: the studio and
165
- * the server are versioned and deployed separately, so a frontend that knows
166
- * about `/logs/stream` will meet servers that do not. A 404 there is not an
167
- * error, it is an older backend, and it degrades to what that backend supports
168
- * instead of showing an empty view.
169
- *
170
- * The stream sends its own backlog as a `snapshot` frame before any `append`, so
171
- * a reconnect restores the window without a second request and without a gap
172
- * where entries logged mid-handshake would belong to neither call.
173
- */
174
- export function useLogTail(filters: LogTailFilters, limit = 200): LogTail {
175
- const [logs, setLogs] = useState<LogEntry[]>([]);
176
- const [error, setError] = useState<string | null>(null);
177
- const [transport, setTransport] = useState<LogTransport>("connecting");
178
- const [dropped, setDropped] = useState(0);
179
- const apiConfig = useApiConfig();
180
- const apiBase = useApiBase();
181
-
182
- // Held in a ref, and deliberately not a dependency of the connection effect.
183
- // `apiConfig` is an object: a provider that forgets to memoize it hands us a
184
- // new identity per render, and as a dependency that is a reconnect per
185
- // render — a tail that hammers the server and never stays up. `apiBase` is a
186
- // string and compares by value, so it can be a dependency and covers the one
187
- // thing that actually has to reopen the connection.
188
- const getAuthToken = apiConfig?.getAuthToken;
189
- const getAuthTokenRef = useRef(getAuthToken);
190
- useEffect(() => {
191
- getAuthTokenRef.current = getAuthToken;
192
- });
193
-
194
- const { level, source, search } = filters;
195
-
196
- // A hidden tab is not watching. The connection is dropped rather than left
197
- // to accumulate, and the reconnect's snapshot rebuilds the window — so this
198
- // costs nothing but the entries nobody saw.
199
- const [visible, setVisible] = useState(
200
- typeof document === "undefined" || document.visibilityState === "visible"
201
- );
202
- useEffect(() => {
203
- const onChange = () => setVisible(document.visibilityState === "visible");
204
- document.addEventListener("visibilitychange", onChange);
205
- return () => document.removeEventListener("visibilitychange", onChange);
206
- }, []);
207
-
208
- // A filter change replaces the window wholesale. Clearing here rather than in
209
- // the connection effect means a reconnect (or a tab coming back) keeps what
210
- // is on screen until the new snapshot lands.
211
- //
212
- // Joined on NUL, written as the escape: the search box takes free text, so any
213
- // printable separator is a character a user can type, and two filters that
214
- // differ only either side of it would collide into one key and skip the clear.
215
- // As a raw byte it would make the whole file binary to grep, which reads as an
216
- // empty search result rather than as an error.
217
- const filterKey = `${level}\u0000${source}\u0000${search}`;
218
- // `EMPTY` rather than a fresh `[]` so the run on mount, when there is nothing
219
- // to clear, is not a state change and does not cost a render.
220
- useEffect(() => {
221
- setLogs(EMPTY);
222
- }, [filterKey]);
223
-
224
- useEffect(() => {
225
- if (!apiBase) {
226
- setError("No API URL configured — cannot load logs.");
227
- return;
228
- }
229
- if (!visible) return;
230
-
231
- let cancelled = false;
232
- let controller: AbortController | null = null;
233
- let timer: ReturnType<typeof setTimeout> | null = null;
234
- let attempt = 0;
235
-
236
- const query = buildParams({ level,
237
- source,
238
- search }, limit);
239
-
240
- const authHeaders = async (): Promise<Record<string, string>> => {
241
- const headers: Record<string, string> = {};
242
- const token = await getAuthTokenRef.current?.() ?? null;
243
- if (token) headers["Authorization"] = `Bearer ${token}`;
244
- return headers;
245
- };
246
-
247
- const retryLater = () => {
248
- if (cancelled) return;
249
- const delay = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_MAX_MS);
250
- attempt++;
251
- timer = setTimeout(connect, delay);
252
- };
253
-
254
- /** The fallback. Only ever reached against a server with no stream route. */
255
- const poll = async () => {
256
- if (cancelled) return;
257
- try {
258
- const resp = await fetch(`${apiBase}/logs?${query}`, { headers: await authHeaders() });
259
- if (cancelled) return;
260
- if (!resp.ok) {
261
- setError(await failureMessage(resp));
262
- } else {
263
- const data: { entries?: LogEntry[] } = await resp.json();
264
- // The query returns newest-first; the view tails like a
265
- // terminal, so flip to chronological order.
266
- const entries = (data.entries || []).slice().reverse();
267
- setLogs(prev => sameLogs(prev, entries) ? prev : entries);
268
- setError(null);
269
- }
270
- } catch (e) {
271
- if (!cancelled) setError(e instanceof Error ? e.message : "Could not load logs.");
272
- }
273
- if (!cancelled) timer = setTimeout(poll, POLL_MS);
274
- };
275
-
276
- const startPolling = () => {
277
- if (cancelled) return;
278
- setTransport("polling");
279
- poll();
280
- };
281
-
282
- const connect = async () => {
283
- if (cancelled) return;
284
- controller = new AbortController();
285
- try {
286
- const resp = await fetch(`${apiBase}/logs/stream?${query}`, {
287
- headers: { ...await authHeaders(),
288
- Accept: "text/event-stream" },
289
- signal: controller.signal
290
- });
291
- if (cancelled) return;
292
-
293
- // 404 is an older server, not a failure — it has the query route
294
- // but not the stream. Same for a body-less response, which is a
295
- // client with no streaming support at all.
296
- if (resp.status === 404 || !resp.body) {
297
- startPolling();
298
- return;
299
- }
300
- if (!resp.ok) {
301
- setError(await failureMessage(resp));
302
- retryLater();
303
- return;
304
- }
305
-
306
- attempt = 0;
307
- setError(null);
308
- setTransport("live");
309
- // Per-connection: the server's counter resets with the socket, so
310
- // a stale total would outlive the gap it described.
311
- setDropped(0);
312
-
313
- for await (const frame of readSSEFrames(resp.body)) {
314
- if (cancelled) return;
315
- if (frame.event === "snapshot") {
316
- const { entries } = JSON.parse(frame.data) as { entries: LogEntry[] };
317
- setLogs(prev => sameLogs(prev, entries) ? prev : capped(entries));
318
- } else if (frame.event === "append") {
319
- const parsed = JSON.parse(frame.data) as { entries: LogEntry[]; dropped?: number };
320
- if (parsed.entries.length > 0) {
321
- setLogs(prev => capped([...prev, ...parsed.entries]));
322
- }
323
- if (parsed.dropped) setDropped(n => n + parsed.dropped!);
324
- } else if (frame.event === "error") {
325
- setError(frame.data);
326
- }
327
- }
328
-
329
- // The stream ended on its own — a restarted or redeployed
330
- // server. Reconnecting is the whole point of the backoff.
331
- if (!cancelled) {
332
- setTransport("connecting");
333
- retryLater();
334
- }
335
- } catch (e) {
336
- if (cancelled || (e instanceof Error && e.name === "AbortError")) return;
337
- setTransport("connecting");
338
- setError(e instanceof Error ? e.message : "Could not load logs.");
339
- retryLater();
340
- }
341
- };
342
-
343
- connect();
344
-
345
- return () => {
346
- cancelled = true;
347
- if (timer) clearTimeout(timer);
348
- controller?.abort();
349
- };
350
- }, [level, source, search, limit, apiBase, visible]);
351
-
352
- return { logs,
353
- error,
354
- transport,
355
- dropped };
356
- }