@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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
// Real HTTP client for a running reddb instance.
|
|
2
|
+
// Endpoints discovered from reddb/crates/reddb-server/src/server/routing.rs.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
capabilitiesFromSignal,
|
|
6
|
+
EMPTY_SERVER_CAPABILITIES,
|
|
7
|
+
type RawCapabilities,
|
|
8
|
+
type ServerCapabilities,
|
|
9
|
+
} from "./server-capabilities";
|
|
10
|
+
|
|
11
|
+
export interface QueryRow {
|
|
12
|
+
values: Record<string, unknown>;
|
|
13
|
+
edges?: Record<string, unknown>;
|
|
14
|
+
nodes?: Record<string, unknown>;
|
|
15
|
+
paths?: unknown[];
|
|
16
|
+
vector_results?: Array<{
|
|
17
|
+
id?: number | string;
|
|
18
|
+
collection?: string;
|
|
19
|
+
distance?: number;
|
|
20
|
+
vector?: number[];
|
|
21
|
+
metadata?: Record<string, unknown>;
|
|
22
|
+
linked_node?: string;
|
|
23
|
+
linked_row?: [string, number];
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface QueryResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
query: string;
|
|
30
|
+
capability?: string;
|
|
31
|
+
engine?: string;
|
|
32
|
+
mode?: string;
|
|
33
|
+
record_count: number;
|
|
34
|
+
result: { columns: string[]; records: QueryRow[] };
|
|
35
|
+
error?: string;
|
|
36
|
+
/** True when this result was assembled from the NDJSON streaming endpoint
|
|
37
|
+
* (`POST /query/stream`) rather than the buffered `POST /query`. */
|
|
38
|
+
streamed?: boolean;
|
|
39
|
+
/** True when streaming stopped at `maxRows` and more rows remained on the
|
|
40
|
+
* server (the cursor was cancelled). Surfaced so the UI never silently
|
|
41
|
+
* drops rows. */
|
|
42
|
+
truncated?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Result of collecting a read-only SELECT over the NDJSON streaming endpoint. */
|
|
46
|
+
export interface StreamedQueryResult {
|
|
47
|
+
columns: string[];
|
|
48
|
+
schemaFingerprint?: string;
|
|
49
|
+
rows: Array<Record<string, unknown>>;
|
|
50
|
+
rowCount: number;
|
|
51
|
+
/** Hit the `maxRows` cap with more rows still on the server. */
|
|
52
|
+
truncated: boolean;
|
|
53
|
+
/** Opaque resume token from the `cursor` frame, if one was emitted. */
|
|
54
|
+
cursor?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CollectionMetadata {
|
|
58
|
+
ok?: boolean;
|
|
59
|
+
name: string;
|
|
60
|
+
kind?: string;
|
|
61
|
+
capability?: string;
|
|
62
|
+
capabilities?: string[];
|
|
63
|
+
schema?: Record<string, unknown>;
|
|
64
|
+
indexes?: Array<Record<string, unknown>>;
|
|
65
|
+
retention?: Record<string, unknown>;
|
|
66
|
+
tenant?: string;
|
|
67
|
+
actions?: Record<string, boolean | { allowed: boolean; reason?: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Stats {
|
|
71
|
+
active_connections: number;
|
|
72
|
+
idle_connections: number;
|
|
73
|
+
started_at_unix_ms: number;
|
|
74
|
+
store: {
|
|
75
|
+
collection_count: number;
|
|
76
|
+
cross_ref_count: number;
|
|
77
|
+
total_entities: number;
|
|
78
|
+
total_memory_bytes: number;
|
|
79
|
+
};
|
|
80
|
+
system: {
|
|
81
|
+
arch: string;
|
|
82
|
+
cpu_cores: number;
|
|
83
|
+
hostname: string;
|
|
84
|
+
os: string;
|
|
85
|
+
pid: number;
|
|
86
|
+
total_memory_bytes: number;
|
|
87
|
+
available_memory_bytes: number;
|
|
88
|
+
};
|
|
89
|
+
kv?: {
|
|
90
|
+
gets: number;
|
|
91
|
+
puts: number;
|
|
92
|
+
deletes: number;
|
|
93
|
+
cas_success: number;
|
|
94
|
+
cas_conflict: number;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Set by the server when the store is open read-only — e.g. an embedded file
|
|
98
|
+
* already held by an active writer (lock detection is owned by reddb). Absent
|
|
99
|
+
* when the server reports no read-only signal, in which case the UI shows no
|
|
100
|
+
* badge and behaves normally (#23). `read_only_reason` is an optional
|
|
101
|
+
* human-facing explanation for the badge tooltip.
|
|
102
|
+
*/
|
|
103
|
+
read_only?: boolean;
|
|
104
|
+
read_only_reason?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ClusterStatus {
|
|
108
|
+
ok?: boolean;
|
|
109
|
+
deployment?: {
|
|
110
|
+
mode?: "embedded" | "server" | "docker" | "replicated" | string;
|
|
111
|
+
file_path?: string;
|
|
112
|
+
process_kind?: string;
|
|
113
|
+
container_id?: string;
|
|
114
|
+
image?: string;
|
|
115
|
+
configured_by?: string;
|
|
116
|
+
};
|
|
117
|
+
storage?: {
|
|
118
|
+
capacity_bytes?: number;
|
|
119
|
+
used_bytes?: number;
|
|
120
|
+
free_bytes?: number;
|
|
121
|
+
};
|
|
122
|
+
wal?: {
|
|
123
|
+
size_bytes?: number;
|
|
124
|
+
lsn?: number;
|
|
125
|
+
oldest_lsn?: number;
|
|
126
|
+
};
|
|
127
|
+
throughput?: {
|
|
128
|
+
requests_per_second?: number;
|
|
129
|
+
reads_per_second?: number;
|
|
130
|
+
writes_per_second?: number;
|
|
131
|
+
avg_response_ms?: number;
|
|
132
|
+
};
|
|
133
|
+
system?: {
|
|
134
|
+
cpu_usage_percent?: number;
|
|
135
|
+
memory_used_bytes?: number;
|
|
136
|
+
memory_total_bytes?: number;
|
|
137
|
+
};
|
|
138
|
+
replication?: {
|
|
139
|
+
replica_count?: number;
|
|
140
|
+
lag_ms?: number;
|
|
141
|
+
lag_lsn?: number;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ReplicationStatus {
|
|
146
|
+
ok: boolean;
|
|
147
|
+
role: "primary" | "replica" | "standalone";
|
|
148
|
+
state?: "healthy" | "lagging" | "disconnected";
|
|
149
|
+
primary_addr?: string;
|
|
150
|
+
/** primary perspective */
|
|
151
|
+
wal_lsn?: number;
|
|
152
|
+
oldest_lsn?: number;
|
|
153
|
+
replica_count?: number;
|
|
154
|
+
/** replica perspective */
|
|
155
|
+
last_applied_lsn?: number;
|
|
156
|
+
last_seen_primary_lsn?: number;
|
|
157
|
+
last_seen_oldest_lsn?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface Whoami {
|
|
161
|
+
ok: boolean;
|
|
162
|
+
authenticated: boolean;
|
|
163
|
+
username: string;
|
|
164
|
+
role: string;
|
|
165
|
+
note?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface RedClientOptions {
|
|
169
|
+
headers?: HeadersInit;
|
|
170
|
+
fetch?: typeof fetch;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface AuthUser {
|
|
174
|
+
username: string;
|
|
175
|
+
role?: string;
|
|
176
|
+
created_at?: number;
|
|
177
|
+
last_active?: number;
|
|
178
|
+
mfa?: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface AuthTenant {
|
|
182
|
+
id?: string;
|
|
183
|
+
name?: string;
|
|
184
|
+
slug?: string;
|
|
185
|
+
role?: string;
|
|
186
|
+
grants?: string[];
|
|
187
|
+
created_at?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface AuthPolicy {
|
|
191
|
+
id?: string;
|
|
192
|
+
name?: string;
|
|
193
|
+
tenant?: string;
|
|
194
|
+
principal?: string;
|
|
195
|
+
resource?: string;
|
|
196
|
+
action?: string;
|
|
197
|
+
effect?: "allow" | "deny" | string;
|
|
198
|
+
description?: string;
|
|
199
|
+
created_at?: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ChangeEvent {
|
|
203
|
+
lsn: number;
|
|
204
|
+
timestamp: number;
|
|
205
|
+
collection: string;
|
|
206
|
+
kind: string;
|
|
207
|
+
operation: "insert" | "update" | "delete" | "refresh";
|
|
208
|
+
rid?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface VcsEnvelope<T> {
|
|
212
|
+
ok: boolean;
|
|
213
|
+
result: T;
|
|
214
|
+
error?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface CollectionVcsState {
|
|
218
|
+
collection: string;
|
|
219
|
+
versioned: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface VcsAuthor {
|
|
223
|
+
name: string;
|
|
224
|
+
email: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface VcsCommit {
|
|
228
|
+
hash: string;
|
|
229
|
+
root_xid?: number;
|
|
230
|
+
parents: string[];
|
|
231
|
+
height?: number;
|
|
232
|
+
author?: VcsAuthor;
|
|
233
|
+
committer?: VcsAuthor;
|
|
234
|
+
message: string;
|
|
235
|
+
timestamp_ms: number;
|
|
236
|
+
signature?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface VcsDiffEntry {
|
|
240
|
+
collection: string;
|
|
241
|
+
entity_id: string;
|
|
242
|
+
change: "added" | "removed" | "modified" | string;
|
|
243
|
+
before?: unknown;
|
|
244
|
+
after?: unknown;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface VcsDiff {
|
|
248
|
+
from: string;
|
|
249
|
+
to: string;
|
|
250
|
+
added: number;
|
|
251
|
+
removed: number;
|
|
252
|
+
modified: number;
|
|
253
|
+
entries: VcsDiffEntry[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const unsupportedCollectionMetadata = new Set<string>();
|
|
257
|
+
const collectionMetadataSupportProbe = new Map<string, Promise<boolean>>();
|
|
258
|
+
|
|
259
|
+
function isUnsupportedCollectionMetadataRoute(e: unknown): boolean {
|
|
260
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
261
|
+
return message.includes("route not found: GET /collections/");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generalised "the server doesn't have this route" check (#22). `json()`
|
|
266
|
+
* throws `<METHOD> <path> → <status> <body>`, so a 404 or a "route not found"
|
|
267
|
+
* body both signal an unsupported route. Distinct from auth/5xx/network errors
|
|
268
|
+
* — but capability negotiation treats *any* probe failure as unsupported
|
|
269
|
+
* (fail safe), so this is only used to tell "route absent" from "signal
|
|
270
|
+
* present" when reading the stable `/capabilities` endpoint.
|
|
271
|
+
*/
|
|
272
|
+
function isUnsupportedRoute(e: unknown): boolean {
|
|
273
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
274
|
+
return / → 404\b/.test(message) || message.includes("route not found");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function unsupportedCollectionMetadataError(baseUrl: string): Error {
|
|
278
|
+
return new Error(
|
|
279
|
+
`GET /collections/:name unsupported by this reddb server (${baseUrl})`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** One NDJSON frame from `POST /query/stream`. Discriminated by its single key. */
|
|
284
|
+
type NdjsonFrame =
|
|
285
|
+
| { descriptor: { columns: string[]; schema_fingerprint?: string } }
|
|
286
|
+
| { cursor: { token: string; snapshot_lsn?: number; resumable?: boolean } }
|
|
287
|
+
| { row: Record<string, unknown> }
|
|
288
|
+
| { end: { row_count?: number } }
|
|
289
|
+
| { cancelled: Record<string, unknown> };
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Parse a chunked `application/x-ndjson` body into one frame per line. Buffers
|
|
293
|
+
* across chunk boundaries (a JSON object split over two TCP reads is emitted
|
|
294
|
+
* exactly once, in order) and flushes a trailing newline-less line at EOF.
|
|
295
|
+
*/
|
|
296
|
+
async function* parseNdjsonFrames(
|
|
297
|
+
body: ReadableStream<Uint8Array>
|
|
298
|
+
): AsyncGenerator<NdjsonFrame> {
|
|
299
|
+
const reader = body.getReader();
|
|
300
|
+
const decoder = new TextDecoder();
|
|
301
|
+
let buf = "";
|
|
302
|
+
try {
|
|
303
|
+
for (;;) {
|
|
304
|
+
const { done, value } = await reader.read();
|
|
305
|
+
if (done) break;
|
|
306
|
+
buf += decoder.decode(value, { stream: true });
|
|
307
|
+
let nl: number;
|
|
308
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
309
|
+
const line = buf.slice(0, nl).trim();
|
|
310
|
+
buf = buf.slice(nl + 1);
|
|
311
|
+
if (line) yield JSON.parse(line) as NdjsonFrame;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const tail = buf.trim();
|
|
315
|
+
if (tail) yield JSON.parse(tail) as NdjsonFrame;
|
|
316
|
+
} finally {
|
|
317
|
+
reader.releaseLock();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const SELECT_RE = /^\s*select\b/i;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Run a query, preferring the bounded-memory NDJSON stream for a plain SELECT
|
|
325
|
+
* and falling back to the buffered `POST /query` for everything else — and for
|
|
326
|
+
* any streaming failure (network, a non-SELECT 400, or an older server with no
|
|
327
|
+
* `/query/stream` route returning 404). The fallback makes this strictly safe:
|
|
328
|
+
* behaviour is identical to {@link RedClient.query} unless the server supports
|
|
329
|
+
* streaming AND the statement is a SELECT, in which case the server streams
|
|
330
|
+
* rows instead of buffering the whole result set.
|
|
331
|
+
*/
|
|
332
|
+
export async function runQueryPreferStream(
|
|
333
|
+
client: Pick<RedClient, "query" | "queryStreamCollect">,
|
|
334
|
+
sql: string,
|
|
335
|
+
opts: { maxRows?: number; signal?: AbortSignal } = {}
|
|
336
|
+
): Promise<QueryResult> {
|
|
337
|
+
if (!SELECT_RE.test(sql)) return client.query(sql);
|
|
338
|
+
try {
|
|
339
|
+
const s = await client.queryStreamCollect(sql, opts);
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
query: sql,
|
|
343
|
+
record_count: s.rowCount,
|
|
344
|
+
result: {
|
|
345
|
+
columns: s.columns,
|
|
346
|
+
records: s.rows.map((values) => ({ values })),
|
|
347
|
+
},
|
|
348
|
+
streamed: true,
|
|
349
|
+
truncated: s.truncated,
|
|
350
|
+
};
|
|
351
|
+
} catch {
|
|
352
|
+
return client.query(sql);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export class RedClient {
|
|
357
|
+
readonly baseUrl: string;
|
|
358
|
+
private readonly headers: HeadersInit | undefined;
|
|
359
|
+
private readonly fetcher: typeof fetch;
|
|
360
|
+
|
|
361
|
+
constructor(baseUrl: string, opts: RedClientOptions = {}) {
|
|
362
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
363
|
+
this.headers = opts.headers;
|
|
364
|
+
this.fetcher = opts.fetch ?? fetch;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async json<T>(path: string, init?: RequestInit): Promise<T> {
|
|
368
|
+
const url = `${this.baseUrl}${path}`;
|
|
369
|
+
const headers: Record<string, string> = {
|
|
370
|
+
"Content-Type": "application/json",
|
|
371
|
+
};
|
|
372
|
+
Object.assign(headers, this.headers as Record<string, string> | undefined);
|
|
373
|
+
Object.assign(headers, init?.headers as Record<string, string> | undefined);
|
|
374
|
+
|
|
375
|
+
const res = await this.fetcher(url, { ...init, headers });
|
|
376
|
+
if (!res.ok) {
|
|
377
|
+
const body = await res.text().catch(() => "");
|
|
378
|
+
throw new Error(
|
|
379
|
+
`${init?.method ?? "GET"} ${path} → ${res.status} ${body.slice(0, 200)}`
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return res.json() as Promise<T>;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async stats(): Promise<Stats> {
|
|
386
|
+
return this.json<Stats>("/stats");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async clusterStatus(): Promise<ClusterStatus> {
|
|
390
|
+
return this.json<ClusterStatus>("/cluster/status");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async ping(): Promise<
|
|
394
|
+
{ ok: true; rtt_ms: number } | { ok: false; error: string }
|
|
395
|
+
> {
|
|
396
|
+
const start = performance.now();
|
|
397
|
+
try {
|
|
398
|
+
await this.json("/stats");
|
|
399
|
+
return { ok: true, rtt_ms: Math.round(performance.now() - start) };
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return { ok: false, error: (e as Error).message };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Resolve the server's capability map (#22). Prefers the stable
|
|
407
|
+
* `/capabilities` signal; when that route is absent (older server), infers
|
|
408
|
+
* from lightweight, side-effect-free GET probes. Any non-404 failure
|
|
409
|
+
* (network, auth, 5xx) fails safe to the empty map, so the UI hides controls
|
|
410
|
+
* rather than showing ones the server can't honour.
|
|
411
|
+
*/
|
|
412
|
+
async capabilities(): Promise<ServerCapabilities> {
|
|
413
|
+
try {
|
|
414
|
+
const raw = await this.json<RawCapabilities>("/capabilities");
|
|
415
|
+
return capabilitiesFromSignal(raw);
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// Only an absent route falls through to inference; anything else (a
|
|
418
|
+
// flaky network, an auth wall, a 5xx) hides everything.
|
|
419
|
+
if (!isUnsupportedRoute(e)) return { ...EMPTY_SERVER_CAPABILITIES };
|
|
420
|
+
}
|
|
421
|
+
const [vcs, clusterStatus] = await Promise.all([
|
|
422
|
+
this.probeGet("/repo/commits?limit=1"),
|
|
423
|
+
this.probeGet("/cluster/status"),
|
|
424
|
+
]);
|
|
425
|
+
return { ...EMPTY_SERVER_CAPABILITIES, vcs, clusterStatus };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** True iff a GET to `path` succeeds; any failure (404/auth/5xx/network) is
|
|
429
|
+
* treated as unsupported — the fail-safe-to-hide rule of #22. */
|
|
430
|
+
private async probeGet(path: string): Promise<boolean> {
|
|
431
|
+
try {
|
|
432
|
+
await this.json(path);
|
|
433
|
+
return true;
|
|
434
|
+
} catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async collections(): Promise<string[]> {
|
|
440
|
+
const r = await this.json<{ collections: string[] }>("/collections");
|
|
441
|
+
return r.collections;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async collection(name: string): Promise<CollectionMetadata> {
|
|
445
|
+
if (unsupportedCollectionMetadata.has(this.baseUrl)) {
|
|
446
|
+
throw unsupportedCollectionMetadataError(this.baseUrl);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const path = `/collections/${encodeURIComponent(name)}`;
|
|
450
|
+
const inFlightProbe = collectionMetadataSupportProbe.get(this.baseUrl);
|
|
451
|
+
if (inFlightProbe) {
|
|
452
|
+
const supported = await inFlightProbe;
|
|
453
|
+
if (!supported) throw unsupportedCollectionMetadataError(this.baseUrl);
|
|
454
|
+
return this.json<CollectionMetadata>(path);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let resolveProbe: (supported: boolean) => void;
|
|
458
|
+
const probe = new Promise<boolean>((resolve) => {
|
|
459
|
+
resolveProbe = resolve;
|
|
460
|
+
});
|
|
461
|
+
collectionMetadataSupportProbe.set(this.baseUrl, probe);
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const metadata = await this.json<CollectionMetadata>(path);
|
|
465
|
+
resolveProbe!(true);
|
|
466
|
+
return metadata;
|
|
467
|
+
} catch (e) {
|
|
468
|
+
if (isUnsupportedCollectionMetadataRoute(e)) {
|
|
469
|
+
unsupportedCollectionMetadata.add(this.baseUrl);
|
|
470
|
+
resolveProbe!(false);
|
|
471
|
+
} else {
|
|
472
|
+
resolveProbe!(true);
|
|
473
|
+
}
|
|
474
|
+
throw e;
|
|
475
|
+
} finally {
|
|
476
|
+
collectionMetadataSupportProbe.delete(this.baseUrl);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async createCollection(name: string): Promise<void> {
|
|
481
|
+
await this.json("/collections", {
|
|
482
|
+
method: "POST",
|
|
483
|
+
body: JSON.stringify({ name }),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async insertDocument(
|
|
488
|
+
collection: string,
|
|
489
|
+
doc: Record<string, unknown>
|
|
490
|
+
): Promise<{ id: number }> {
|
|
491
|
+
return this.json<{ id: number }>(
|
|
492
|
+
`/collections/${encodeURIComponent(collection)}/documents`,
|
|
493
|
+
{
|
|
494
|
+
method: "POST",
|
|
495
|
+
body: JSON.stringify(doc),
|
|
496
|
+
}
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async query(query: string): Promise<QueryResult> {
|
|
501
|
+
return this.json<QueryResult>("/query", {
|
|
502
|
+
method: "POST",
|
|
503
|
+
body: JSON.stringify({ query }),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Stream a read-only SELECT through `POST /query/stream` (NDJSON, chunked).
|
|
509
|
+
* The server emits rows through a bounded-memory channel — it never buffers
|
|
510
|
+
* the whole result — and we collect up to `maxRows` (default 10 000) before
|
|
511
|
+
* cancelling the server cursor. The NDJSON frame order is fixed
|
|
512
|
+
* (`descriptor` → `cursor` → `row`* → `end`|`cancelled`), verified against
|
|
513
|
+
* ../reddb/docs/api/query-streaming.md.
|
|
514
|
+
*
|
|
515
|
+
* Throws for a non-SELECT (server `400 stream_unsupported_statement`) and for
|
|
516
|
+
* an absent route on an older server (`404`) — callers fall back to
|
|
517
|
+
* {@link query}. Browser-reachable: this is `fetch` + `ReadableStream`, not
|
|
518
|
+
* `EventSource` (the endpoint is POST-based).
|
|
519
|
+
*/
|
|
520
|
+
async queryStreamCollect(
|
|
521
|
+
query: string,
|
|
522
|
+
opts: { maxRows?: number; signal?: AbortSignal } = {}
|
|
523
|
+
): Promise<StreamedQueryResult> {
|
|
524
|
+
const maxRows = opts.maxRows ?? 10_000;
|
|
525
|
+
const headers: Record<string, string> = {
|
|
526
|
+
"Content-Type": "application/json",
|
|
527
|
+
Accept: "application/x-ndjson",
|
|
528
|
+
};
|
|
529
|
+
Object.assign(headers, this.headers as Record<string, string> | undefined);
|
|
530
|
+
|
|
531
|
+
const res = await this.fetcher(`${this.baseUrl}/query/stream`, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers,
|
|
534
|
+
body: JSON.stringify({ query }),
|
|
535
|
+
signal: opts.signal,
|
|
536
|
+
});
|
|
537
|
+
if (!res.ok || !res.body) {
|
|
538
|
+
const body = res.body ? await res.text().catch(() => "") : "";
|
|
539
|
+
throw new Error(
|
|
540
|
+
`POST /query/stream → ${res.status} ${body.slice(0, 200)}`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const out: StreamedQueryResult = {
|
|
545
|
+
columns: [],
|
|
546
|
+
rows: [],
|
|
547
|
+
rowCount: 0,
|
|
548
|
+
truncated: false,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
for await (const frame of parseNdjsonFrames(res.body)) {
|
|
552
|
+
if ("descriptor" in frame) {
|
|
553
|
+
out.columns = frame.descriptor.columns ?? [];
|
|
554
|
+
out.schemaFingerprint = frame.descriptor.schema_fingerprint;
|
|
555
|
+
} else if ("cursor" in frame) {
|
|
556
|
+
out.cursor = frame.cursor.token;
|
|
557
|
+
} else if ("row" in frame) {
|
|
558
|
+
if (out.rows.length >= maxRows) {
|
|
559
|
+
out.truncated = true;
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
out.rows.push(frame.row);
|
|
563
|
+
out.rowCount = out.rows.length;
|
|
564
|
+
} else if ("end" in frame || "cancelled" in frame) {
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// We stopped early with more rows on the server — release the pinned cursor.
|
|
570
|
+
if (out.truncated && out.cursor) {
|
|
571
|
+
await this.cancelQueryStream(out.cursor).catch(() => {
|
|
572
|
+
// best-effort: the cursor TTL reclaims it anyway.
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Cancel a streaming-query cursor server-side (`POST /query/stream/cancel`). */
|
|
579
|
+
async cancelQueryStream(cursor: string): Promise<void> {
|
|
580
|
+
await this.json("/query/stream/cancel", {
|
|
581
|
+
method: "POST",
|
|
582
|
+
body: JSON.stringify({ cursor }),
|
|
583
|
+
}).catch(() => undefined);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async collectionVcs(name: string): Promise<CollectionVcsState> {
|
|
587
|
+
const r = await this.json<VcsEnvelope<CollectionVcsState>>(
|
|
588
|
+
`/collections/${encodeURIComponent(name)}/vcs`
|
|
589
|
+
);
|
|
590
|
+
return r.result;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async setCollectionVcs(
|
|
594
|
+
name: string,
|
|
595
|
+
versioned: boolean
|
|
596
|
+
): Promise<CollectionVcsState> {
|
|
597
|
+
const r = await this.json<VcsEnvelope<CollectionVcsState>>(
|
|
598
|
+
`/collections/${encodeURIComponent(name)}/vcs`,
|
|
599
|
+
{
|
|
600
|
+
method: "PUT",
|
|
601
|
+
body: JSON.stringify({ versioned }),
|
|
602
|
+
}
|
|
603
|
+
);
|
|
604
|
+
return r.result;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
async commits(
|
|
608
|
+
opts: {
|
|
609
|
+
from?: string;
|
|
610
|
+
to?: string;
|
|
611
|
+
branch?: string;
|
|
612
|
+
limit?: number;
|
|
613
|
+
skip?: number;
|
|
614
|
+
noMerges?: boolean;
|
|
615
|
+
} = {}
|
|
616
|
+
): Promise<VcsCommit[]> {
|
|
617
|
+
const qs = new URLSearchParams();
|
|
618
|
+
if (opts.from) qs.set("from", opts.from);
|
|
619
|
+
if (opts.to) qs.set("to", opts.to);
|
|
620
|
+
if (opts.branch) qs.set("branch", opts.branch);
|
|
621
|
+
if (opts.limit !== undefined) qs.set("limit", String(opts.limit));
|
|
622
|
+
if (opts.skip !== undefined) qs.set("skip", String(opts.skip));
|
|
623
|
+
if (opts.noMerges !== undefined) qs.set("no_merges", String(opts.noMerges));
|
|
624
|
+
const suffix = qs.toString() ? `?${qs}` : "";
|
|
625
|
+
const r = await this.json<VcsEnvelope<VcsCommit[]>>(
|
|
626
|
+
`/repo/commits${suffix}`
|
|
627
|
+
);
|
|
628
|
+
return r.result;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async commitDiff(
|
|
632
|
+
from: string,
|
|
633
|
+
to: string,
|
|
634
|
+
opts: { collection?: string; summary?: boolean } = {}
|
|
635
|
+
): Promise<VcsDiff> {
|
|
636
|
+
const qs = new URLSearchParams();
|
|
637
|
+
if (opts.collection) qs.set("collection", opts.collection);
|
|
638
|
+
if (opts.summary !== undefined) qs.set("summary", String(opts.summary));
|
|
639
|
+
const suffix = qs.toString() ? `?${qs}` : "";
|
|
640
|
+
const r = await this.json<VcsEnvelope<VcsDiff>>(
|
|
641
|
+
`/repo/commits/${encodeURIComponent(from)}/diff/${encodeURIComponent(to)}${suffix}`
|
|
642
|
+
);
|
|
643
|
+
return r.result;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async replication(): Promise<ReplicationStatus> {
|
|
647
|
+
return this.json<ReplicationStatus>("/replication/status").catch(() => ({
|
|
648
|
+
ok: false,
|
|
649
|
+
role: "standalone" as const,
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async whoami(): Promise<Whoami> {
|
|
654
|
+
return this.json<Whoami>("/auth/whoami");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async users(): Promise<AuthUser[]> {
|
|
658
|
+
const r = await this.json<{ ok: boolean; users: AuthUser[] }>(
|
|
659
|
+
"/auth/users"
|
|
660
|
+
);
|
|
661
|
+
return r.users;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
async tenants(): Promise<AuthTenant[]> {
|
|
665
|
+
const r = await this.json<{ ok: boolean; tenants: AuthTenant[] }>(
|
|
666
|
+
"/auth/tenants"
|
|
667
|
+
);
|
|
668
|
+
return r.tenants ?? [];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async policies(): Promise<AuthPolicy[]> {
|
|
672
|
+
const r = await this.json<{ ok: boolean; policies: AuthPolicy[] }>(
|
|
673
|
+
"/auth/policies"
|
|
674
|
+
);
|
|
675
|
+
return r.policies ?? [];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async changes(
|
|
679
|
+
sinceLsn?: number,
|
|
680
|
+
opts: { collection?: string; limit?: number } = {}
|
|
681
|
+
): Promise<ChangeEvent[]> {
|
|
682
|
+
const qs = new URLSearchParams();
|
|
683
|
+
if (sinceLsn !== undefined) qs.set("since_lsn", String(sinceLsn));
|
|
684
|
+
if (opts.limit !== undefined) qs.set("limit", String(opts.limit));
|
|
685
|
+
const suffix = qs.toString() ? `?${qs}` : "";
|
|
686
|
+
const r = await this.json<{ events: ChangeEvent[] }>("/changes" + suffix);
|
|
687
|
+
const events = r.events ?? [];
|
|
688
|
+
return opts.collection
|
|
689
|
+
? events.filter((e) => e.collection === opts.collection)
|
|
690
|
+
: events;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Convenience: turn a /query response into plain row objects.
|
|
694
|
+
async rows(query: string): Promise<Array<Record<string, unknown>>> {
|
|
695
|
+
const r = await this.query(query);
|
|
696
|
+
return r.result.records.map((rec) => rec.values);
|
|
697
|
+
}
|
|
698
|
+
}
|