@reddb-io/ui 0.1.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 (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,245 @@
1
+ // LocalUrlProvider — the OSS implementation of ConnectionProvider. Wraps
2
+ // a list of well-known presets and a recent-history backing store. The
3
+ // browser usage backs history with localStorage; tests inject an
4
+ // in-memory store via the options bag.
5
+
6
+ import { RedClient, type RedClientOptions } from "./client";
7
+ import {
8
+ NotConnectedError,
9
+ UnknownConnectionError,
10
+ UnreachableConnectionError,
11
+ type ActiveConnection,
12
+ type Connection,
13
+ type ConnectionProvider,
14
+ type Identity,
15
+ } from "./connection-provider";
16
+ import { BROWSER_TRANSPORTS } from "./transports";
17
+ import type { Transport } from "./types";
18
+ import { hasBootEndpoint, hasBootRoute, type BootParams } from "./boot-params";
19
+
20
+ export interface HistoryEntry {
21
+ url: string;
22
+ label: string;
23
+ last_used: number;
24
+ rtt_ms?: number;
25
+ }
26
+
27
+ export interface HistoryStore {
28
+ load(): HistoryEntry[];
29
+ save(entries: HistoryEntry[]): void;
30
+ }
31
+
32
+ export interface LocalUrlProviderOptions {
33
+ /** Built-in connections, surfaced first in `list()`. */
34
+ presets?: Connection[];
35
+ /** Backing store for the recent-URL history. Defaults to in-memory. */
36
+ history?: HistoryStore;
37
+ /** Hook for tests / Tauri to swap the HTTP client implementation. */
38
+ clientFactory?: (url: string, opts?: RedClientOptions) => RedClient;
39
+ /** Max history entries retained. */
40
+ historyMax?: number;
41
+ /**
42
+ * Wire transports this Surface can reach (#34, ADR-0003). Defaults to the
43
+ * browser-reachable set (http/https + coerced red://); a Tauri Surface passes
44
+ * the desktop set so native unix/embedded connections are offered.
45
+ */
46
+ transports?: Transport[];
47
+ /**
48
+ * Non-secret boot config seeded by the Surface (#36, ADR-0005) — endpoint +
49
+ * initial view from the app URL. Surfaced via `bootParams()` so the Core can
50
+ * auto-connect without the Connect flow. Tokens must never be placed here.
51
+ */
52
+ bootParams?: BootParams;
53
+ }
54
+
55
+ export interface HandoffTokenConsumer {
56
+ useHandoffToken(url: string, token: string): void;
57
+ }
58
+
59
+ const HISTORY_MAX_DEFAULT = 8;
60
+
61
+ function inMemoryStore(): HistoryStore {
62
+ let entries: HistoryEntry[] = [];
63
+ return {
64
+ load: () => entries.slice(),
65
+ save: (next) => {
66
+ entries = next.slice();
67
+ },
68
+ };
69
+ }
70
+
71
+ export function localStorageHistory(key: string): HistoryStore {
72
+ return {
73
+ load() {
74
+ if (typeof localStorage === "undefined") return [];
75
+ try {
76
+ const raw = localStorage.getItem(key);
77
+ if (!raw) return [];
78
+ const parsed = JSON.parse(raw) as HistoryEntry[];
79
+ return Array.isArray(parsed) ? parsed : [];
80
+ } catch {
81
+ return [];
82
+ }
83
+ },
84
+ save(entries) {
85
+ if (typeof localStorage === "undefined") return;
86
+ try {
87
+ localStorage.setItem(key, JSON.stringify(entries));
88
+ } catch {
89
+ /* quota or disabled — drop silently */
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ export class LocalUrlProvider implements ConnectionProvider {
96
+ private readonly presets: Connection[];
97
+ private readonly history: HistoryStore;
98
+ private readonly clientFactory: (
99
+ url: string,
100
+ opts?: RedClientOptions
101
+ ) => RedClient;
102
+ private readonly historyMax: number;
103
+ private readonly supportedTransports: Transport[];
104
+ private readonly boot: BootParams | null;
105
+ private readonly handoffTokens = new Map<string, string>();
106
+ private active: ActiveConnection | null = null;
107
+
108
+ constructor(opts: LocalUrlProviderOptions = {}) {
109
+ this.presets = opts.presets?.slice() ?? [];
110
+ this.history = opts.history ?? inMemoryStore();
111
+ this.historyMax = opts.historyMax ?? HISTORY_MAX_DEFAULT;
112
+ this.clientFactory =
113
+ opts.clientFactory ??
114
+ ((url, clientOpts) => new RedClient(url, clientOpts));
115
+ this.supportedTransports = opts.transports?.slice() ?? [
116
+ ...BROWSER_TRANSPORTS,
117
+ ];
118
+ this.boot =
119
+ opts.bootParams &&
120
+ (hasBootEndpoint(opts.bootParams) || hasBootRoute(opts.bootParams))
121
+ ? { ...opts.bootParams }
122
+ : null;
123
+ }
124
+
125
+ transports(): Transport[] {
126
+ return this.supportedTransports.slice();
127
+ }
128
+
129
+ bootParams(): BootParams | null {
130
+ return this.boot ? { ...this.boot } : null;
131
+ }
132
+
133
+ useHandoffToken(url: string, token: string): void {
134
+ const clean = token.trim();
135
+ if (!clean) return;
136
+ this.handoffTokens.set(this.handoffKey(url), clean);
137
+ }
138
+
139
+ async list(): Promise<Connection[]> {
140
+ const seen = new Set<string>();
141
+ const out: Connection[] = [];
142
+ for (const p of this.presets) {
143
+ if (seen.has(p.url)) continue;
144
+ seen.add(p.url);
145
+ out.push(p);
146
+ }
147
+ for (const h of this.history.load()) {
148
+ if (seen.has(h.url)) continue;
149
+ seen.add(h.url);
150
+ out.push({ id: h.url, label: h.label, url: h.url, role: "primary" });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Connect by id. The id matches a preset id, a history URL, or a raw URL
157
+ * the caller wants to attempt ad-hoc — that last case is what makes this
158
+ * a *URL* provider rather than a registry. Unknown raw strings that
159
+ * don't look like URLs throw UnknownConnectionError.
160
+ */
161
+ async connect(id: string): Promise<ActiveConnection> {
162
+ const target = this.resolve(id);
163
+ if (!target) throw new UnknownConnectionError(id);
164
+
165
+ const token = this.consumeHandoffToken(target.url);
166
+ const client = this.clientFactory(
167
+ target.url,
168
+ token ? { headers: { Authorization: `Bearer ${token}` } } : undefined
169
+ );
170
+ const ping = await client.ping();
171
+ if (!ping.ok) throw new UnreachableConnectionError(target.id, ping.error);
172
+
173
+ const active: ActiveConnection = {
174
+ connection: target,
175
+ client,
176
+ rtt_ms: ping.rtt_ms,
177
+ };
178
+ this.active = active;
179
+ this.record(target, ping.rtt_ms);
180
+ return active;
181
+ }
182
+
183
+ async whoami(): Promise<Identity> {
184
+ if (!this.active) throw new NotConnectedError();
185
+ const w = await this.active.client.whoami();
186
+ return {
187
+ authenticated: w.authenticated,
188
+ username: w.username,
189
+ role: w.role,
190
+ };
191
+ }
192
+
193
+ /** Test/UI helper: remove a URL from history. Not part of the contract. */
194
+ forget(url: string): void {
195
+ const next = this.history.load().filter((e) => e.url !== url);
196
+ this.history.save(next);
197
+ }
198
+
199
+ private resolve(id: string): Connection | null {
200
+ const preset = this.presets.find((p) => p.id === id);
201
+ if (preset) return preset;
202
+
203
+ const hit = this.history.load().find((e) => e.url === id);
204
+ if (hit)
205
+ return { id: hit.url, label: hit.label, url: hit.url, role: "primary" };
206
+
207
+ if (/^[a-z+]+:\/\//i.test(id)) {
208
+ let host = id;
209
+ try {
210
+ host = new URL(id).host || id;
211
+ } catch {
212
+ /* fall through */
213
+ }
214
+ return {
215
+ id,
216
+ label: host,
217
+ url: id,
218
+ role: "primary",
219
+ description: "Ad-hoc URL.",
220
+ };
221
+ }
222
+
223
+ return null;
224
+ }
225
+
226
+ private handoffKey(url: string): string {
227
+ return url.replace(/\/$/, "");
228
+ }
229
+
230
+ private consumeHandoffToken(url: string): string | null {
231
+ const key = this.handoffKey(url);
232
+ const token = this.handoffTokens.get(key) ?? null;
233
+ this.handoffTokens.delete(key);
234
+ return token;
235
+ }
236
+
237
+ private record(c: Connection, rtt_ms: number): void {
238
+ const existing = this.history.load().filter((e) => e.url !== c.url);
239
+ const next: HistoryEntry[] = [
240
+ { url: c.url, label: c.label, last_used: Date.now(), rtt_ms },
241
+ ...existing,
242
+ ].slice(0, this.historyMax);
243
+ this.history.save(next);
244
+ }
245
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * EncryptedStore — deep module for credential-grade key/value storage.
3
+ *
4
+ * Two implementations are shipped:
5
+ *
6
+ * - WebEncryptedStore: derives an AES-GCM key from a session-scoped
7
+ * master password (PBKDF2/SHA-256, 200k iterations) and writes
8
+ * {ciphertext, nonce, salt} to localStorage.
9
+ *
10
+ * - TauriEncryptedStore: defers to the OS keychain (Keychain on
11
+ * macOS, Credential Manager on Windows, libsecret on Linux) via
12
+ * a tiny set of `invoke` commands implemented in Rust.
13
+ *
14
+ * Both implementations share the same surface, the same error vocabulary,
15
+ * and the same tamper-detection guarantees.
16
+ */
17
+
18
+ export interface EncryptedStore {
19
+ put(key: string, value: string): Promise<void>
20
+ get(key: string): Promise<string | null>
21
+ delete(key: string): Promise<void>
22
+ }
23
+
24
+ export class SecureStoreError extends Error {
25
+ readonly code: SecureStoreErrorCode
26
+ constructor(code: SecureStoreErrorCode, message: string) {
27
+ super(message)
28
+ this.name = 'SecureStoreError'
29
+ this.code = code
30
+ }
31
+ }
32
+
33
+ export type SecureStoreErrorCode =
34
+ /** Decryption of stored ciphertext failed — wrong key or tampered payload. */
35
+ | 'DECRYPT_FAILED'
36
+ /** Stored envelope is missing required fields or is structurally invalid. */
37
+ | 'CORRUPT_ENVELOPE'
38
+ /** Backing store (localStorage, keychain) refused the operation. */
39
+ | 'BACKEND_FAILURE'
40
+ /** Store accessed before unlock/key derivation. */
41
+ | 'LOCKED'
@@ -0,0 +1,71 @@
1
+ // Server-version capability negotiation (#22). One UI build talks to many
2
+ // reddb server versions: rather than assume every endpoint exists, the UI
3
+ // resolves a capability map for the active connection and HIDES controls the
4
+ // server can't honour (absent, not greyed — the permission-aware rule).
5
+ //
6
+ // Resolution prefers a STABLE server signal (`GET /capabilities`) over
7
+ // inferring from 404s, and FAILS SAFE — any uncertainty resolves to "not
8
+ // supported" so a newer UI never assumes a capability a server lacks.
9
+
10
+ /** The feature flags the UI gates controls on. Extend as features are added. */
11
+ export interface ServerCapabilities {
12
+ /** Version control (commits / diffs) — `/repo/commits`, `/collections/:n/vcs`. */
13
+ vcs: boolean
14
+ /** Cluster topology + status — `/cluster/status`. */
15
+ clusterStatus: boolean
16
+ /** Per-collection metadata route — `GET /collections/:name`. */
17
+ collectionMetadata: boolean
18
+ /** Server-sent change stream — `/changes/stream`. */
19
+ cdcStream: boolean
20
+ }
21
+
22
+ /** Fail-safe baseline: nothing is assumed present until proven. */
23
+ export const EMPTY_SERVER_CAPABILITIES: ServerCapabilities = Object.freeze({
24
+ vcs: false,
25
+ clusterStatus: false,
26
+ collectionMetadata: false,
27
+ cdcStream: false,
28
+ })
29
+
30
+ /** Shape of the optional stable `/capabilities` signal. */
31
+ export interface RawCapabilities {
32
+ ok?: boolean
33
+ /** Flat feature names, e.g. ["vcs","cluster_status"]. */
34
+ features?: string[]
35
+ /** Or an explicit flag map, e.g. { vcs: true }. */
36
+ capabilities?: Record<string, boolean>
37
+ }
38
+
39
+ // Server feature names → our capability keys. Several aliases per key so we
40
+ // tolerate naming drift across server versions without a UI release.
41
+ const FEATURE_ALIASES: Record<string, keyof ServerCapabilities> = {
42
+ vcs: 'vcs',
43
+ versioning: 'vcs',
44
+ version_control: 'vcs',
45
+ cluster: 'clusterStatus',
46
+ cluster_status: 'clusterStatus',
47
+ collection_metadata: 'collectionMetadata',
48
+ collections_metadata: 'collectionMetadata',
49
+ cdc: 'cdcStream',
50
+ changes_stream: 'cdcStream',
51
+ cdc_stream: 'cdcStream',
52
+ }
53
+
54
+ /**
55
+ * Build a capability map from the stable `/capabilities` signal. Unknown
56
+ * feature names are ignored (a server advertising features this UI version
57
+ * doesn't model is harmless); known ones flip their flag on. The explicit
58
+ * `capabilities` map, when present, takes precedence over the `features` list.
59
+ */
60
+ export function capabilitiesFromSignal(raw: RawCapabilities): ServerCapabilities {
61
+ const out: ServerCapabilities = { ...EMPTY_SERVER_CAPABILITIES }
62
+ for (const name of raw.features ?? []) {
63
+ const key = FEATURE_ALIASES[name?.toLowerCase?.() ?? '']
64
+ if (key) out[key] = true
65
+ }
66
+ for (const [name, on] of Object.entries(raw.capabilities ?? {})) {
67
+ const key = FEATURE_ALIASES[name.toLowerCase()]
68
+ if (key) out[key] = on === true
69
+ }
70
+ return out
71
+ }
@@ -0,0 +1,64 @@
1
+ import { type EncryptedStore, SecureStoreError } from './secure-store'
2
+
3
+ export type TauriInvoke = <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
4
+
5
+ export interface TauriEncryptedStoreOptions {
6
+ /** Service name passed to the OS keychain. */
7
+ service?: string
8
+ /** Bound to the running Tauri runtime — pass `invoke` from `@tauri-apps/api/core`. */
9
+ invoke: TauriInvoke
10
+ }
11
+
12
+ /**
13
+ * Tauri-backed encrypted store. Defers entirely to the OS keychain via
14
+ * three commands implemented in Rust (see apps/desktop/src-tauri):
15
+ *
16
+ * keychain_set { service, key, value } → void
17
+ * keychain_get { service, key } → string | null
18
+ * keychain_delete { service, key } → void
19
+ *
20
+ * No master password prompt: trust is rooted in the OS user session.
21
+ */
22
+ export class TauriEncryptedStore implements EncryptedStore {
23
+ private readonly invoke: TauriInvoke
24
+ private readonly service: string
25
+
26
+ constructor(opts: TauriEncryptedStoreOptions) {
27
+ if (!opts.invoke) {
28
+ throw new SecureStoreError('BACKEND_FAILURE', 'tauri invoke fn not provided')
29
+ }
30
+ this.invoke = opts.invoke
31
+ this.service = opts.service ?? 'io.reddb.red-ui'
32
+ }
33
+
34
+ async put(key: string, value: string): Promise<void> {
35
+ try {
36
+ await this.invoke<void>('keychain_set', { service: this.service, key, value })
37
+ } catch (e) {
38
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_set failed: ${stringifyError(e)}`)
39
+ }
40
+ }
41
+
42
+ async get(key: string): Promise<string | null> {
43
+ try {
44
+ const v = await this.invoke<string | null>('keychain_get', { service: this.service, key })
45
+ return v ?? null
46
+ } catch (e) {
47
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_get failed: ${stringifyError(e)}`)
48
+ }
49
+ }
50
+
51
+ async delete(key: string): Promise<void> {
52
+ try {
53
+ await this.invoke<void>('keychain_delete', { service: this.service, key })
54
+ } catch (e) {
55
+ throw new SecureStoreError('BACKEND_FAILURE', `keychain_delete failed: ${stringifyError(e)}`)
56
+ }
57
+ }
58
+ }
59
+
60
+ function stringifyError(e: unknown): string {
61
+ if (e instanceof Error) return e.message
62
+ if (typeof e === 'string') return e
63
+ try { return JSON.stringify(e) } catch { return String(e) }
64
+ }
@@ -0,0 +1,89 @@
1
+ // Transport reachability (#34, ADR-0003). Which transports a connection can use
2
+ // is bounded by what the *red-ui client* speaks — NOT by the browser, and NOT by
3
+ // what the reddb server exposes. reddb itself offers many wire transports (HTTP
4
+ // + SSE/NDJSON, RedWire on :5050, gRPC, Postgres-wire, and a WebSocket ingest
5
+ // endpoint `WS /ws/ingest/{collection}` — see ../reddb/docs/api/). The red-ui
6
+ // client, however, currently speaks only HTTP: RedClient is fetch-based (REST),
7
+ // and live changes use SSE/REST polling over the same http(s) connection. So the
8
+ // Connect UI offers http(s) only because that is the client's surface today — a
9
+ // `ws`/`wss` (or `grpc`) transport belongs here only once the client actually
10
+ // consumes reddb's WebSocket/gRPC endpoint, not before (that would be a phantom
11
+ // option that fails when picked).
12
+ //
13
+ // `red://`/`reds://` are not distinct client transports either: normalizeUrl
14
+ // rewrites them to `http(s)://host:5055` (the documented `red://localhost`
15
+ // production tunnel — red-ui's HTTP convention, distinct from reddb's native
16
+ // RedWire on :5050), so they classify as their coerced http(s) transport and are
17
+ // reachable wherever http(s) is. Streaming rides over the http(s) connection, so
18
+ // it needs no separate transport member. Only unix-socket / embedded-file
19
+ // connections are reachable solely from a Tauri Surface, so those are the ones a
20
+ // browser Surface hides. The Connect UI offers only the transports the active
21
+ // provider declares reachable, so the user never picks one that fails. This
22
+ // module is the pure scheme↔transport mapping + reachability sets; the provider
23
+ // declares its set and the UI consults it.
24
+
25
+ import type { Transport } from "./types";
26
+
27
+ /**
28
+ * User-typed connection-string scheme → the wire transport it resolves to.
29
+ * `red`/`reds` (and their `red+tcp`/`red+wire`/`red+tls` aliases) are sugar for
30
+ * `http(s)://host:5055` via normalizeUrl, so they map to `http`/`https` — they
31
+ * carry no distinct wire transport in any Surface today. Native tcp/tls remains
32
+ * a possible future Desktop capability (ADR-0003) but is not materialized yet.
33
+ */
34
+ const SCHEME_TRANSPORT: Record<string, Transport> = {
35
+ http: "http",
36
+ https: "https",
37
+ red: "http",
38
+ "red+tcp": "http",
39
+ "red+wire": "http",
40
+ reds: "https",
41
+ "red+tls": "https",
42
+ "red+http": "http",
43
+ "red+https": "https",
44
+ "red+unix": "unix",
45
+ file: "unix",
46
+ unix: "unix",
47
+ };
48
+
49
+ /**
50
+ * The transport a user-typed connection string would use. Defaults to `http`
51
+ * for a bare `host:port` (matching normalizeUrl, which prepends http://).
52
+ * Returns `null` for a scheme this client doesn't recognise at all.
53
+ */
54
+ export function transportForUrl(input: string): Transport | null {
55
+ const raw = input.trim();
56
+ if (!raw) return null;
57
+ const m = raw.match(/^([a-z+]+):\/\//i);
58
+ if (!m) return "http"; // bare host:port → http (normalizeUrl prepends it)
59
+ return SCHEME_TRANSPORT[m[1].toLowerCase()] ?? null;
60
+ }
61
+
62
+ /**
63
+ * Browser-reachable transports: `http`/`https` only (ADR-0003). `red://`/`reds://`
64
+ * are still reachable here because they coerce to `http(s)://host:5055` and so
65
+ * classify as `http`/`https` — the documented `red://localhost` production
66
+ * tunnel keeps working — but the browser advertises no distinct red(s):// or
67
+ * file:// transport. Unix-socket / embedded-file connections are genuinely
68
+ * unreachable from a browser and are hidden.
69
+ */
70
+ export const BROWSER_TRANSPORTS: readonly Transport[] = ["http", "https"];
71
+
72
+ /** A Tauri Surface adds the native unix-socket / embedded-file transport. */
73
+ export const DESKTOP_TRANSPORTS: readonly Transport[] = [
74
+ "http",
75
+ "https",
76
+ "unix",
77
+ ];
78
+
79
+ /**
80
+ * Whether a user-typed URL's transport is reachable from a provider that
81
+ * declares `supported`. An unrecognised scheme is never reachable.
82
+ */
83
+ export function isUrlReachable(
84
+ input: string,
85
+ supported: readonly Transport[]
86
+ ): boolean {
87
+ const t = transportForUrl(input);
88
+ return t !== null && supported.includes(t);
89
+ }
@@ -0,0 +1,32 @@
1
+ export type NodeRole = 'primary' | 'replica' | 'embedded'
2
+
3
+ export type Transport = 'tcp' | 'tls' | 'unix' | 'http' | 'https'
4
+
5
+ export interface ConnectionTarget {
6
+ transport: Transport
7
+ host: string
8
+ port?: number
9
+ path?: string
10
+ database?: string
11
+ username?: string
12
+ password?: string
13
+ role?: NodeRole
14
+ params: Record<string, string>
15
+ }
16
+
17
+ export interface NodeStats {
18
+ id: string
19
+ role: NodeRole
20
+ host: string
21
+ bytesUsed: number
22
+ bytesAvailable: number
23
+ keyCount: number
24
+ lagBytes?: number
25
+ uptimeSeconds: number
26
+ }
27
+
28
+ export interface Topology {
29
+ primary: NodeStats
30
+ replicas: NodeStats[]
31
+ observedAt: string
32
+ }
@@ -0,0 +1,54 @@
1
+ import type { ConnectionTarget, NodeRole, Transport } from './types'
2
+
3
+ const TRANSPORTS: Record<string, Transport> = {
4
+ red: 'tcp',
5
+ 'red+tls': 'tls',
6
+ 'red+unix': 'unix',
7
+ 'red+http': 'http',
8
+ 'red+https': 'https',
9
+ }
10
+
11
+ export function parseRedUri(uri: string): ConnectionTarget {
12
+ const match = uri.match(/^([a-z+]+):\/\/(.+)$/i)
13
+ if (!match) throw new Error(`Invalid red:// URI: ${uri}`)
14
+ const [, scheme, rest] = match
15
+
16
+ const transport = TRANSPORTS[scheme.toLowerCase()]
17
+ if (!transport) throw new Error(`Unsupported scheme: ${scheme}`)
18
+
19
+ if (transport === 'unix') {
20
+ const [path, query = ''] = rest.split('?')
21
+ return {
22
+ transport,
23
+ host: 'localhost',
24
+ path: decodeURIComponent(path),
25
+ params: parseQuery(query),
26
+ }
27
+ }
28
+
29
+ const url = new URL(`http://${rest}`)
30
+ const params = parseQuery(url.search.slice(1))
31
+ const role = params.role as NodeRole | undefined
32
+ const database = url.pathname.replace(/^\//, '') || undefined
33
+
34
+ return {
35
+ transport,
36
+ host: url.hostname,
37
+ port: url.port ? Number(url.port) : undefined,
38
+ database,
39
+ username: url.username ? decodeURIComponent(url.username) : undefined,
40
+ password: url.password ? decodeURIComponent(url.password) : undefined,
41
+ role,
42
+ params,
43
+ }
44
+ }
45
+
46
+ function parseQuery(qs: string): Record<string, string> {
47
+ const out: Record<string, string> = {}
48
+ if (!qs) return out
49
+ for (const pair of qs.split('&')) {
50
+ const [k, v = ''] = pair.split('=')
51
+ if (k) out[decodeURIComponent(k)] = decodeURIComponent(v)
52
+ }
53
+ return out
54
+ }