@reddb-io/ui 0.3.0 → 0.3.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/build/_app/immutable/assets/0.DgbuWrJy.css +1 -0
- package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
- package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
- package/build/_app/immutable/chunks/85-Fh5Zp.js +404 -0
- package/build/_app/immutable/chunks/{C9rXzYLu.js → 8zg2gJwV.js} +1 -1
- package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
- package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
- package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
- package/build/_app/immutable/chunks/Bq4DTWK2.js +2 -0
- package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
- package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
- package/build/_app/immutable/chunks/CJFJ501n.js +18 -0
- package/build/_app/immutable/chunks/CLBx21Hz.js +2 -0
- package/build/_app/immutable/chunks/CnD4hwxC.js +1106 -0
- package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
- package/build/_app/immutable/chunks/DQCYUNtS.js +1 -0
- package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
- package/build/_app/immutable/chunks/{C278awGv.js → DV9PDoM3.js} +1 -1
- package/build/_app/immutable/entry/app.BDog2y0J.js +2 -0
- package/build/_app/immutable/entry/start.D1J45dq5.js +1 -0
- package/build/_app/immutable/nodes/0.BTrC4th2.js +1 -0
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +1 -0
- package/build/_app/immutable/nodes/10.D_FCLs0Z.js +96 -0
- package/build/_app/immutable/nodes/12.DPE6v6Zf.js +3 -0
- package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/15.CcJlZGxa.js +60 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -1
- package/build/index.html +18 -15
- package/dist/embed/AnalyticsView-D8z1Nsed.js +23337 -0
- package/dist/embed/AnalyticsView-D8z1Nsed.js.map +1 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +31302 -0
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +1 -0
- package/dist/embed/index-0hA141D2.js +85 -0
- package/dist/embed/index-0hA141D2.js.map +1 -0
- package/dist/embed/index-client-BeiP-va5.js +3303 -0
- package/dist/embed/index-client-BeiP-va5.js.map +1 -0
- package/dist/embed/index-w1Qo_uW0.js +677 -0
- package/dist/embed/index-w1Qo_uW0.js.map +1 -0
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-qqKBpvin.js} +2 -2
- package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-qqKBpvin.js.map} +1 -1
- package/package.json +5 -3
- package/src/app.css +86 -20
- package/src/lib/AnalyticsView.svelte +209 -0
- package/src/lib/AskView.svelte +255 -0
- package/src/lib/ClusterView.svelte +415 -95
- package/src/lib/CommandPalette.svelte +2 -0
- package/src/lib/GlobalProgressBar.svelte +9 -0
- package/src/lib/MetricChart.svelte +85 -0
- package/src/lib/QueryEditor.svelte +48 -7
- package/src/lib/ResultsPane.svelte +76 -8
- package/src/lib/SchemaTree.svelte +25 -1
- package/src/lib/SecurityView.svelte +176 -65
- package/src/lib/SettingsView.svelte +1111 -254
- package/src/lib/Topbar.svelte +29 -1
- package/src/lib/Workspace.svelte +18 -3
- package/src/lib/activity.svelte.ts +44 -35
- package/src/lib/collection-preview.ts +14 -0
- package/src/lib/connections.svelte.ts +73 -4
- package/src/lib/index.ts +3 -0
- package/src/lib/permission-gate.ts +120 -0
- package/src/lib/policies.ts +136 -0
- package/src/lib/query-tabs.svelte.ts +132 -53
- package/src/lib/reddb/analytics.ts +208 -0
- package/src/lib/reddb/ask.ts +123 -0
- package/src/lib/reddb/client.ts +159 -58
- package/src/lib/reddb/cluster-status.ts +306 -0
- package/src/lib/reddb/index.ts +3 -0
- package/src/lib/reddb/local-url-provider.ts +32 -1
- package/src/lib/reddb/redwire-ws-spike.ts +590 -0
- package/src/lib/router.svelte.ts +12 -0
- package/src/lib/settings-authoring-client.ts +336 -0
- package/src/lib/settings-sections.ts +292 -196
- package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
- package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
- package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
- package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
- package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
- package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
- package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
- package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
- package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
- package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
- package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
- package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
- package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
- package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
- package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
- package/dist/embed/index-DGhgtnaK.js +0 -577
- package/dist/embed/index-DGhgtnaK.js.map +0 -1
- package/dist/embed/index-client-ItCEU64C.js +0 -3087
- package/dist/embed/index-client-ItCEU64C.js.map +0 -1
- /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
- /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
const REDWIRE_MAGIC = 0xfe;
|
|
2
|
+
const REDWIRE_MINOR = 0x01;
|
|
3
|
+
const FRAME_HEADER_SIZE = 16;
|
|
4
|
+
const MAX_FRAME_SIZE = 16 * 1024 * 1024;
|
|
5
|
+
const KNOWN_FLAGS = 0b0000_0011;
|
|
6
|
+
const WS_CONNECTING = 0;
|
|
7
|
+
const WS_OPEN = 1;
|
|
8
|
+
|
|
9
|
+
export const REDWIRE_WS_PATH = "/redwire";
|
|
10
|
+
export const REDWIRE_WS_SUBPROTOCOL = "reddb.redwire.v1";
|
|
11
|
+
|
|
12
|
+
export const MessageKind = {
|
|
13
|
+
Query: 0x01,
|
|
14
|
+
Result: 0x02,
|
|
15
|
+
Error: 0x03,
|
|
16
|
+
QueryBinary: 0x07,
|
|
17
|
+
Hello: 0x10,
|
|
18
|
+
HelloAck: 0x11,
|
|
19
|
+
AuthResponse: 0x13,
|
|
20
|
+
AuthOk: 0x14,
|
|
21
|
+
AuthFail: 0x15,
|
|
22
|
+
Bye: 0x16,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type RedWireMessageKind = (typeof MessageKind)[keyof typeof MessageKind];
|
|
26
|
+
|
|
27
|
+
const KIND_NAME = new Map<number, string>(
|
|
28
|
+
Object.entries(MessageKind).map(([name, value]) => [value, name])
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export interface RedWireFrame {
|
|
32
|
+
kind: RedWireMessageKind | number;
|
|
33
|
+
flags: number;
|
|
34
|
+
streamId: number;
|
|
35
|
+
correlationId: bigint;
|
|
36
|
+
payload: Uint8Array;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DecodedRedWireFrame extends RedWireFrame {
|
|
40
|
+
consumed: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RedWireQueryResult {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
statement?: string;
|
|
46
|
+
affected?: number;
|
|
47
|
+
columns: string[];
|
|
48
|
+
rows: Array<Record<string, unknown>>;
|
|
49
|
+
raw: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RedWireWsSpikeResult {
|
|
53
|
+
endpoint: string;
|
|
54
|
+
subprotocol: string;
|
|
55
|
+
session: Record<string, unknown>;
|
|
56
|
+
helloAck: Record<string, unknown>;
|
|
57
|
+
query: RedWireQueryResult;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class RedWireSpikeError extends Error {
|
|
61
|
+
constructor(
|
|
62
|
+
readonly code: string,
|
|
63
|
+
message: string
|
|
64
|
+
) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "RedWireSpikeError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function redwireWsUrlFromHttp(url: string): string {
|
|
71
|
+
const parsed = new URL(url);
|
|
72
|
+
if (parsed.protocol === "wss:") return parsed.toString();
|
|
73
|
+
if (parsed.protocol === "https:") {
|
|
74
|
+
parsed.protocol = "wss:";
|
|
75
|
+
} else if (parsed.protocol === "http:") {
|
|
76
|
+
parsed.protocol = "ws:";
|
|
77
|
+
}
|
|
78
|
+
if (parsed.pathname === "/" || parsed.pathname === "") {
|
|
79
|
+
parsed.pathname = REDWIRE_WS_PATH;
|
|
80
|
+
}
|
|
81
|
+
return parsed.toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function encodeFrame(
|
|
85
|
+
kind: RedWireMessageKind | number,
|
|
86
|
+
correlationId: bigint | number,
|
|
87
|
+
payload: Uint8Array | ArrayBuffer | ArrayLike<number>,
|
|
88
|
+
flags = 0,
|
|
89
|
+
streamId = 0
|
|
90
|
+
): Uint8Array {
|
|
91
|
+
const body =
|
|
92
|
+
payload instanceof Uint8Array ? payload : new Uint8Array(payload);
|
|
93
|
+
const length = FRAME_HEADER_SIZE + body.byteLength;
|
|
94
|
+
if (length > MAX_FRAME_SIZE) {
|
|
95
|
+
throw new RedWireSpikeError(
|
|
96
|
+
"FRAME_TOO_LARGE",
|
|
97
|
+
`frame ${length} exceeds ${MAX_FRAME_SIZE}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const bytes = new Uint8Array(length);
|
|
101
|
+
const view = new DataView(bytes.buffer);
|
|
102
|
+
view.setUint32(0, length, true);
|
|
103
|
+
bytes[4] = kind & 0xff;
|
|
104
|
+
bytes[5] = flags & KNOWN_FLAGS;
|
|
105
|
+
view.setUint16(6, streamId, true);
|
|
106
|
+
view.setBigUint64(8, BigInt(correlationId), true);
|
|
107
|
+
bytes.set(body, FRAME_HEADER_SIZE);
|
|
108
|
+
return bytes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function decodeFrame(bytes: Uint8Array): DecodedRedWireFrame | null {
|
|
112
|
+
if (bytes.byteLength < FRAME_HEADER_SIZE) return null;
|
|
113
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
114
|
+
const length = view.getUint32(0, true);
|
|
115
|
+
if (length < FRAME_HEADER_SIZE || length > MAX_FRAME_SIZE) {
|
|
116
|
+
throw new RedWireSpikeError("FRAME_INVALID_LENGTH", `length=${length}`);
|
|
117
|
+
}
|
|
118
|
+
if (bytes.byteLength < length) return null;
|
|
119
|
+
const flags = bytes[5];
|
|
120
|
+
if (flags & ~KNOWN_FLAGS) {
|
|
121
|
+
throw new RedWireSpikeError(
|
|
122
|
+
"FRAME_UNKNOWN_FLAGS",
|
|
123
|
+
`flags=0x${flags.toString(16)}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
kind: bytes[4],
|
|
128
|
+
flags,
|
|
129
|
+
streamId: view.getUint16(6, true),
|
|
130
|
+
correlationId: view.getBigUint64(8, true),
|
|
131
|
+
payload: bytes.slice(FRAME_HEADER_SIZE, length),
|
|
132
|
+
consumed: length,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function encodePreamble(): Uint8Array {
|
|
137
|
+
return Uint8Array.from([REDWIRE_MAGIC, REDWIRE_MINOR]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function decodeResultPayload(payload: Uint8Array): RedWireQueryResult {
|
|
141
|
+
const json = parseJson(payload);
|
|
142
|
+
if (json && typeof json === "object") {
|
|
143
|
+
const obj = json as Record<string, unknown>;
|
|
144
|
+
const columns = Array.isArray(obj.columns)
|
|
145
|
+
? obj.columns.filter((c): c is string => typeof c === "string")
|
|
146
|
+
: [];
|
|
147
|
+
const rows = Array.isArray(obj.rows)
|
|
148
|
+
? obj.rows.filter(isRecord)
|
|
149
|
+
: Array.isArray(
|
|
150
|
+
(obj.result as Record<string, unknown> | undefined)?.records
|
|
151
|
+
)
|
|
152
|
+
? ((obj.result as Record<string, unknown>).records as unknown[]).filter(
|
|
153
|
+
isRecord
|
|
154
|
+
)
|
|
155
|
+
: [];
|
|
156
|
+
return {
|
|
157
|
+
ok: obj.ok !== false,
|
|
158
|
+
statement: stringField(obj.statement) ?? stringField(obj.query),
|
|
159
|
+
affected: numberField(obj.affected),
|
|
160
|
+
columns,
|
|
161
|
+
rows,
|
|
162
|
+
raw: json,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return decodeBinaryResultPayload(payload);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function runRedWireWsSpike({
|
|
169
|
+
url,
|
|
170
|
+
token,
|
|
171
|
+
query,
|
|
172
|
+
timeoutMs = 10_000,
|
|
173
|
+
WebSocketImpl = globalThis.WebSocket,
|
|
174
|
+
}: {
|
|
175
|
+
url: string;
|
|
176
|
+
token: string;
|
|
177
|
+
query: string;
|
|
178
|
+
timeoutMs?: number;
|
|
179
|
+
WebSocketImpl?: typeof WebSocket;
|
|
180
|
+
}): Promise<RedWireWsSpikeResult> {
|
|
181
|
+
if (typeof WebSocketImpl !== "function") {
|
|
182
|
+
throw new RedWireSpikeError("NO_WEBSOCKET", "WebSocket is unavailable");
|
|
183
|
+
}
|
|
184
|
+
if (!url.startsWith("wss://")) {
|
|
185
|
+
throw new RedWireSpikeError(
|
|
186
|
+
"WSS_REQUIRED",
|
|
187
|
+
`RedWire WS requires wss://, got ${url}`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (!token.trim()) {
|
|
191
|
+
throw new RedWireSpikeError("TOKEN_REQUIRED", "Bearer token is required");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const socket = new BrowserWebSocketDuplex(
|
|
195
|
+
new WebSocketImpl(url, REDWIRE_WS_SUBPROTOCOL)
|
|
196
|
+
);
|
|
197
|
+
await socket.open(timeoutMs);
|
|
198
|
+
const reader = new FrameReader(socket);
|
|
199
|
+
const enc = new TextEncoder();
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
socket.write(encodePreamble());
|
|
203
|
+
socket.write(
|
|
204
|
+
encodeFrame(
|
|
205
|
+
MessageKind.Hello,
|
|
206
|
+
1n,
|
|
207
|
+
jsonBytes({
|
|
208
|
+
versions: [REDWIRE_MINOR],
|
|
209
|
+
auth_methods: ["bearer"],
|
|
210
|
+
features: 0,
|
|
211
|
+
client_name: "red-ui/redwire-ws-spike",
|
|
212
|
+
})
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const helloAck = await expectFrame(
|
|
217
|
+
reader,
|
|
218
|
+
[MessageKind.HelloAck, MessageKind.AuthFail],
|
|
219
|
+
"HelloAck"
|
|
220
|
+
);
|
|
221
|
+
if (helloAck.kind === MessageKind.AuthFail) {
|
|
222
|
+
throw new RedWireSpikeError(
|
|
223
|
+
"AUTH_REFUSED",
|
|
224
|
+
parseReason(helloAck.payload) ?? "AuthFail at HelloAck"
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const helloAckPayload = expectJsonObject(helloAck.payload, "HelloAck");
|
|
228
|
+
if (helloAckPayload.auth !== "bearer") {
|
|
229
|
+
throw new RedWireSpikeError(
|
|
230
|
+
"AUTH_UNSUPPORTED",
|
|
231
|
+
`server picked ${String(helloAckPayload.auth)}, expected bearer`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
socket.write(
|
|
236
|
+
encodeFrame(
|
|
237
|
+
MessageKind.AuthResponse,
|
|
238
|
+
2n,
|
|
239
|
+
jsonBytes({ token: token.trim() })
|
|
240
|
+
)
|
|
241
|
+
);
|
|
242
|
+
const authOk = await expectFrame(
|
|
243
|
+
reader,
|
|
244
|
+
[MessageKind.AuthOk, MessageKind.AuthFail],
|
|
245
|
+
"AuthOk"
|
|
246
|
+
);
|
|
247
|
+
if (authOk.kind === MessageKind.AuthFail) {
|
|
248
|
+
throw new RedWireSpikeError(
|
|
249
|
+
"AUTH_REFUSED",
|
|
250
|
+
parseReason(authOk.payload) ?? "auth refused"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const session = expectJsonObject(authOk.payload, "AuthOk");
|
|
254
|
+
|
|
255
|
+
const correlationId = 3n;
|
|
256
|
+
socket.write(
|
|
257
|
+
encodeFrame(
|
|
258
|
+
MessageKind.QueryBinary,
|
|
259
|
+
correlationId,
|
|
260
|
+
enc.encode(query),
|
|
261
|
+
0,
|
|
262
|
+
1
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
const reply = await expectFrame(
|
|
266
|
+
reader,
|
|
267
|
+
[MessageKind.Result, MessageKind.Error],
|
|
268
|
+
"query result"
|
|
269
|
+
);
|
|
270
|
+
if (reply.correlationId !== correlationId) {
|
|
271
|
+
throw new RedWireSpikeError(
|
|
272
|
+
"CORRELATION_MISMATCH",
|
|
273
|
+
`expected correlation ${correlationId}, got ${reply.correlationId}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (reply.kind === MessageKind.Error) {
|
|
277
|
+
throw new RedWireSpikeError(
|
|
278
|
+
"QUERY_FAILED",
|
|
279
|
+
new TextDecoder().decode(reply.payload)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
endpoint: url,
|
|
285
|
+
subprotocol: REDWIRE_WS_SUBPROTOCOL,
|
|
286
|
+
session,
|
|
287
|
+
helloAck: helloAckPayload,
|
|
288
|
+
query: decodeResultPayload(reply.payload),
|
|
289
|
+
};
|
|
290
|
+
} finally {
|
|
291
|
+
socket.close();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type SocketEvent = "data" | "error" | "close";
|
|
296
|
+
|
|
297
|
+
class BrowserWebSocketDuplex {
|
|
298
|
+
readonly #ws: WebSocket;
|
|
299
|
+
readonly #handlers: Record<SocketEvent, Array<(arg?: unknown) => void>> = {
|
|
300
|
+
data: [],
|
|
301
|
+
error: [],
|
|
302
|
+
close: [],
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
constructor(ws: WebSocket) {
|
|
306
|
+
this.#ws = ws;
|
|
307
|
+
this.#ws.binaryType = "arraybuffer";
|
|
308
|
+
this.#ws.addEventListener("message", (event) => {
|
|
309
|
+
const bytes = bytesFromMessage(event.data);
|
|
310
|
+
if (bytes) this.#emit("data", bytes);
|
|
311
|
+
});
|
|
312
|
+
this.#ws.addEventListener("error", () => {
|
|
313
|
+
this.#emit(
|
|
314
|
+
"error",
|
|
315
|
+
new RedWireSpikeError("WS_ERROR", "WebSocket transport error")
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
this.#ws.addEventListener("close", () => this.#emit("close"));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
on(event: SocketEvent, cb: (arg?: unknown) => void): void {
|
|
322
|
+
this.#handlers[event].push(cb);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
open(timeoutMs: number): Promise<void> {
|
|
326
|
+
return new Promise((resolve, reject) => {
|
|
327
|
+
if (this.#ws.readyState === WS_OPEN) {
|
|
328
|
+
resolve();
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const timeout = setTimeout(() => {
|
|
332
|
+
cleanup();
|
|
333
|
+
reject(new RedWireSpikeError("WS_TIMEOUT", "WebSocket open timed out"));
|
|
334
|
+
}, timeoutMs);
|
|
335
|
+
const cleanup = () => {
|
|
336
|
+
clearTimeout(timeout);
|
|
337
|
+
this.#ws.removeEventListener("open", onOpen);
|
|
338
|
+
this.#ws.removeEventListener("error", onError);
|
|
339
|
+
this.#ws.removeEventListener("close", onClose);
|
|
340
|
+
};
|
|
341
|
+
const onOpen = () => {
|
|
342
|
+
cleanup();
|
|
343
|
+
resolve();
|
|
344
|
+
};
|
|
345
|
+
const onError = () => {
|
|
346
|
+
cleanup();
|
|
347
|
+
reject(
|
|
348
|
+
new RedWireSpikeError("WS_CONNECT_FAILED", "WebSocket failed to open")
|
|
349
|
+
);
|
|
350
|
+
};
|
|
351
|
+
const onClose = () => {
|
|
352
|
+
cleanup();
|
|
353
|
+
reject(
|
|
354
|
+
new RedWireSpikeError(
|
|
355
|
+
"WS_CONNECT_FAILED",
|
|
356
|
+
"WebSocket closed before opening"
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
};
|
|
360
|
+
this.#ws.addEventListener("open", onOpen);
|
|
361
|
+
this.#ws.addEventListener("error", onError);
|
|
362
|
+
this.#ws.addEventListener("close", onClose);
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
write(bytes: Uint8Array): void {
|
|
367
|
+
this.#ws.send(bytes);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
close(): void {
|
|
371
|
+
if (
|
|
372
|
+
this.#ws.readyState === WS_OPEN ||
|
|
373
|
+
this.#ws.readyState === WS_CONNECTING
|
|
374
|
+
) {
|
|
375
|
+
this.#ws.close();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#emit(event: SocketEvent, arg?: unknown): void {
|
|
380
|
+
for (const cb of this.#handlers[event]) cb(arg);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
class FrameReader {
|
|
385
|
+
#buffer = new Uint8Array();
|
|
386
|
+
#waiters: Array<{
|
|
387
|
+
resolve: (frame: DecodedRedWireFrame) => void;
|
|
388
|
+
reject: (err: unknown) => void;
|
|
389
|
+
}> = [];
|
|
390
|
+
#error: unknown = null;
|
|
391
|
+
#closed = false;
|
|
392
|
+
|
|
393
|
+
constructor(socket: BrowserWebSocketDuplex) {
|
|
394
|
+
socket.on("data", (chunk) => {
|
|
395
|
+
if (!(chunk instanceof Uint8Array)) return;
|
|
396
|
+
const next = new Uint8Array(this.#buffer.byteLength + chunk.byteLength);
|
|
397
|
+
next.set(this.#buffer, 0);
|
|
398
|
+
next.set(chunk, this.#buffer.byteLength);
|
|
399
|
+
this.#buffer = next;
|
|
400
|
+
this.#drain();
|
|
401
|
+
});
|
|
402
|
+
socket.on("error", (err) => {
|
|
403
|
+
this.#error = err;
|
|
404
|
+
this.#flush();
|
|
405
|
+
});
|
|
406
|
+
socket.on("close", () => {
|
|
407
|
+
this.#closed = true;
|
|
408
|
+
this.#flush();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
next(): Promise<DecodedRedWireFrame> {
|
|
413
|
+
if (this.#error) return Promise.reject(this.#error);
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
this.#waiters.push({ resolve, reject });
|
|
416
|
+
this.#drain();
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#drain(): void {
|
|
421
|
+
while (this.#waiters.length > 0 && this.#buffer.byteLength > 0) {
|
|
422
|
+
const frame = decodeFrame(this.#buffer);
|
|
423
|
+
if (!frame) return;
|
|
424
|
+
this.#buffer = this.#buffer.slice(frame.consumed);
|
|
425
|
+
this.#waiters.shift()?.resolve(frame);
|
|
426
|
+
}
|
|
427
|
+
this.#flush();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#flush(): void {
|
|
431
|
+
if (!this.#error && (!this.#closed || this.#buffer.byteLength > 0)) return;
|
|
432
|
+
const err =
|
|
433
|
+
this.#error ??
|
|
434
|
+
new RedWireSpikeError("CONNECTION_CLOSED", "RedWire WS closed");
|
|
435
|
+
while (this.#waiters.length > 0) this.#waiters.shift()?.reject(err);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function expectFrame(
|
|
440
|
+
reader: FrameReader,
|
|
441
|
+
kinds: number[],
|
|
442
|
+
label: string
|
|
443
|
+
): Promise<DecodedRedWireFrame> {
|
|
444
|
+
const frame = await reader.next();
|
|
445
|
+
if (!kinds.includes(frame.kind)) {
|
|
446
|
+
throw new RedWireSpikeError(
|
|
447
|
+
"UNEXPECTED_FRAME",
|
|
448
|
+
`expected ${label}, got ${KIND_NAME.get(frame.kind) ?? frame.kind}`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return frame;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function jsonBytes(value: unknown): Uint8Array {
|
|
455
|
+
return new TextEncoder().encode(JSON.stringify(value));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function parseJson(bytes: Uint8Array): unknown {
|
|
459
|
+
if (bytes.byteLength === 0) return null;
|
|
460
|
+
try {
|
|
461
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
462
|
+
} catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function expectJsonObject(
|
|
468
|
+
bytes: Uint8Array,
|
|
469
|
+
label: string
|
|
470
|
+
): Record<string, unknown> {
|
|
471
|
+
const parsed = parseJson(bytes);
|
|
472
|
+
if (!isRecord(parsed)) {
|
|
473
|
+
throw new RedWireSpikeError("PROTOCOL", `${label} payload is not JSON`);
|
|
474
|
+
}
|
|
475
|
+
return parsed;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function parseReason(payload: Uint8Array): string | null {
|
|
479
|
+
const parsed = parseJson(payload);
|
|
480
|
+
if (!isRecord(parsed)) return null;
|
|
481
|
+
return stringField(parsed.reason) ?? null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function decodeBinaryResultPayload(payload: Uint8Array): RedWireQueryResult {
|
|
485
|
+
const view = new DataView(
|
|
486
|
+
payload.buffer,
|
|
487
|
+
payload.byteOffset,
|
|
488
|
+
payload.byteLength
|
|
489
|
+
);
|
|
490
|
+
const dec = new TextDecoder();
|
|
491
|
+
let pos = 0;
|
|
492
|
+
const read = (n: number, label: string): number => {
|
|
493
|
+
if (pos + n > payload.byteLength) {
|
|
494
|
+
throw new RedWireSpikeError(
|
|
495
|
+
"RESULT_TRUNCATED",
|
|
496
|
+
`Result payload truncated while reading ${label}`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const start = pos;
|
|
500
|
+
pos += n;
|
|
501
|
+
return start;
|
|
502
|
+
};
|
|
503
|
+
const readU16 = (label: string) => view.getUint16(read(2, label), true);
|
|
504
|
+
const readU32 = (label: string) => view.getUint32(read(4, label), true);
|
|
505
|
+
const readText = (n: number, label: string) =>
|
|
506
|
+
dec.decode(payload.subarray(read(n, label), pos));
|
|
507
|
+
const readI64 = (label: string) =>
|
|
508
|
+
jsNumberOrBigInt(view.getBigInt64(read(8, label), true));
|
|
509
|
+
const readU64 = (label: string) =>
|
|
510
|
+
jsNumberOrBigInt(view.getBigUint64(read(8, label), true));
|
|
511
|
+
|
|
512
|
+
const columnCount = readU16("column count");
|
|
513
|
+
const columns: string[] = [];
|
|
514
|
+
for (let i = 0; i < columnCount; i += 1) {
|
|
515
|
+
const length = readU16(`column ${i} length`);
|
|
516
|
+
columns.push(readText(length, `column ${i} name`));
|
|
517
|
+
}
|
|
518
|
+
const rowCount = readU32("row count");
|
|
519
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
520
|
+
for (let r = 0; r < rowCount; r += 1) {
|
|
521
|
+
const row: Record<string, unknown> = {};
|
|
522
|
+
for (const column of columns) row[column] = readBinaryValue();
|
|
523
|
+
rows.push(row);
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
statement: "SELECT",
|
|
528
|
+
affected: 0,
|
|
529
|
+
columns,
|
|
530
|
+
rows,
|
|
531
|
+
raw: { columns, rows },
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
function readBinaryValue(): unknown {
|
|
535
|
+
const tag = payload[read(1, "value tag")];
|
|
536
|
+
switch (tag) {
|
|
537
|
+
case 0:
|
|
538
|
+
return null;
|
|
539
|
+
case 1:
|
|
540
|
+
return readI64("i64 value");
|
|
541
|
+
case 2:
|
|
542
|
+
return view.getFloat64(read(8, "f64 value"), true);
|
|
543
|
+
case 3: {
|
|
544
|
+
const length = readU32("text length");
|
|
545
|
+
return readText(length, "text value");
|
|
546
|
+
}
|
|
547
|
+
case 4:
|
|
548
|
+
return payload[read(1, "bool value")] !== 0;
|
|
549
|
+
case 5:
|
|
550
|
+
return readU64("u64 value");
|
|
551
|
+
default:
|
|
552
|
+
throw new RedWireSpikeError(
|
|
553
|
+
"RESULT_UNKNOWN_TAG",
|
|
554
|
+
`Result payload has unknown value tag ${tag}`
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function bytesFromMessage(data: unknown): Uint8Array | null {
|
|
561
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
562
|
+
if (ArrayBuffer.isView(data)) {
|
|
563
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
569
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function stringField(value: unknown): string | undefined {
|
|
573
|
+
return typeof value === "string" ? value : undefined;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function numberField(value: unknown): number | undefined {
|
|
577
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
578
|
+
? value
|
|
579
|
+
: undefined;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function jsNumberOrBigInt(value: bigint): number | bigint {
|
|
583
|
+
if (
|
|
584
|
+
value >= BigInt(Number.MIN_SAFE_INTEGER) &&
|
|
585
|
+
value <= BigInt(Number.MAX_SAFE_INTEGER)
|
|
586
|
+
) {
|
|
587
|
+
return Number(value);
|
|
588
|
+
}
|
|
589
|
+
return value;
|
|
590
|
+
}
|
package/src/lib/router.svelte.ts
CHANGED
|
@@ -19,10 +19,12 @@ import { collectionPageHref } from "./collection-pages";
|
|
|
19
19
|
|
|
20
20
|
/** The top-level destinations of the Mountable Root. */
|
|
21
21
|
export type View =
|
|
22
|
+
| "ask"
|
|
22
23
|
| "query"
|
|
23
24
|
| "collections"
|
|
24
25
|
| "cluster"
|
|
25
26
|
| "security"
|
|
27
|
+
| "analytics"
|
|
26
28
|
| "settings"
|
|
27
29
|
| "appearance";
|
|
28
30
|
|
|
@@ -31,10 +33,12 @@ export type View =
|
|
|
31
33
|
* within the collections workspace; the other targets are the bare views.
|
|
32
34
|
*/
|
|
33
35
|
export type RouteTarget =
|
|
36
|
+
| { view: "ask" }
|
|
34
37
|
| { view: "query" }
|
|
35
38
|
| { view: "collections" }
|
|
36
39
|
| { view: "cluster" }
|
|
37
40
|
| { view: "security" }
|
|
41
|
+
| { view: "analytics" }
|
|
38
42
|
| { view: "settings" }
|
|
39
43
|
| { view: "appearance" }
|
|
40
44
|
| { view: "collection"; collection: string; subpage: string };
|
|
@@ -71,6 +75,8 @@ export interface Router {
|
|
|
71
75
|
/** Map a `RouteTarget` to its canonical path. */
|
|
72
76
|
export function targetToHref(target: RouteTarget): string {
|
|
73
77
|
switch (target.view) {
|
|
78
|
+
case "ask":
|
|
79
|
+
return "/ask";
|
|
74
80
|
case "query":
|
|
75
81
|
return "/query";
|
|
76
82
|
case "collections":
|
|
@@ -79,6 +85,8 @@ export function targetToHref(target: RouteTarget): string {
|
|
|
79
85
|
return "/cluster";
|
|
80
86
|
case "security":
|
|
81
87
|
return "/security";
|
|
88
|
+
case "analytics":
|
|
89
|
+
return "/analytics";
|
|
82
90
|
case "settings":
|
|
83
91
|
return "/settings";
|
|
84
92
|
case "appearance":
|
|
@@ -106,12 +114,16 @@ export function targetToLocation(target: RouteTarget): RouteLocation {
|
|
|
106
114
|
* `/collections` for the bare collections workspace.
|
|
107
115
|
*/
|
|
108
116
|
export function pathToLocation(pathname: string): RouteLocation {
|
|
117
|
+
if (pathname.startsWith("/ask"))
|
|
118
|
+
return { view: "ask", collection: null, subpage: null };
|
|
109
119
|
if (pathname.startsWith("/query"))
|
|
110
120
|
return { view: "query", collection: null, subpage: null };
|
|
111
121
|
if (pathname.startsWith("/cluster"))
|
|
112
122
|
return { view: "cluster", collection: null, subpage: null };
|
|
113
123
|
if (pathname.startsWith("/security"))
|
|
114
124
|
return { view: "security", collection: null, subpage: null };
|
|
125
|
+
if (pathname.startsWith("/analytics"))
|
|
126
|
+
return { view: "analytics", collection: null, subpage: null };
|
|
115
127
|
if (pathname.startsWith("/settings"))
|
|
116
128
|
return { view: "settings", collection: null, subpage: null };
|
|
117
129
|
if (pathname.startsWith("/appearance"))
|