@reddb-io/ui 0.3.0 → 0.3.2

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 (121) hide show
  1. package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
  5. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  6. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  7. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  8. package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
  9. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  10. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  11. package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
  12. package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
  13. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  14. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  15. package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
  16. package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
  17. package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
  18. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  19. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  20. package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
  21. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  22. package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
  23. package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
  24. package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
  25. package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
  26. package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
  27. package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
  28. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  29. package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
  30. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  31. package/build/_app/version.json +1 -1
  32. package/build/index.html +18 -15
  33. package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
  34. package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
  35. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
  36. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
  37. package/dist/embed/event-BvRbkLjD.js +46 -0
  38. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  39. package/dist/embed/index-0hA141D2.js +85 -0
  40. package/dist/embed/index-0hA141D2.js.map +1 -0
  41. package/dist/embed/index-BVxMkfHe.js +8 -0
  42. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  43. package/dist/embed/index-BY6fc1oQ.js +856 -0
  44. package/dist/embed/index-BY6fc1oQ.js.map +1 -0
  45. package/dist/embed/index-client-BHUUkI8H.js +3304 -0
  46. package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
  47. package/dist/embed/index.js +1 -1
  48. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
  49. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
  50. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  51. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  52. package/package.json +7 -3
  53. package/src/app.css +86 -20
  54. package/src/lib/AnalyticsView.svelte +209 -0
  55. package/src/lib/AskView.svelte +255 -0
  56. package/src/lib/ClusterView.svelte +415 -95
  57. package/src/lib/CommandPalette.svelte +2 -0
  58. package/src/lib/ConnectDropdown.svelte +60 -3
  59. package/src/lib/DevConsole.svelte +155 -0
  60. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  61. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  62. package/src/lib/ErrorBoundary.svelte +78 -0
  63. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  64. package/src/lib/GlobalProgressBar.svelte +9 -0
  65. package/src/lib/LoadingOverlay.svelte +109 -0
  66. package/src/lib/MetricChart.svelte +85 -0
  67. package/src/lib/QueryEditor.svelte +48 -7
  68. package/src/lib/ResultsPane.svelte +76 -8
  69. package/src/lib/SchemaTree.svelte +25 -1
  70. package/src/lib/SecurityView.svelte +176 -65
  71. package/src/lib/SettingsView.svelte +1111 -254
  72. package/src/lib/StatusBar.svelte +19 -1
  73. package/src/lib/Topbar.svelte +29 -1
  74. package/src/lib/Workspace.svelte +136 -5
  75. package/src/lib/activity.svelte.ts +44 -35
  76. package/src/lib/collection-preview.ts +14 -0
  77. package/src/lib/connections.svelte.ts +167 -4
  78. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  79. package/src/lib/embed/index.ts +34 -1
  80. package/src/lib/index.ts +10 -0
  81. package/src/lib/loading.svelte.ts +142 -0
  82. package/src/lib/permission-gate.ts +120 -0
  83. package/src/lib/policies.ts +136 -0
  84. package/src/lib/query-tabs.svelte.ts +132 -53
  85. package/src/lib/reddb/analytics.ts +208 -0
  86. package/src/lib/reddb/ask.ts +123 -0
  87. package/src/lib/reddb/client.ts +292 -73
  88. package/src/lib/reddb/cluster-status.ts +306 -0
  89. package/src/lib/reddb/desktop-error.ts +89 -0
  90. package/src/lib/reddb/dev-console.ts +186 -0
  91. package/src/lib/reddb/index.ts +5 -0
  92. package/src/lib/reddb/local-url-provider.ts +28 -1
  93. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  94. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  95. package/src/lib/router.svelte.ts +12 -0
  96. package/src/lib/settings-authoring-client.ts +336 -0
  97. package/src/lib/settings-sections.ts +292 -196
  98. package/src/lib/tabs.svelte.ts +5 -0
  99. package/src/lib/workspace-session.ts +81 -0
  100. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  101. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  102. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  103. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  104. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  105. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  106. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  107. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  108. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  109. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  110. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  111. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  112. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  113. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  114. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  115. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  116. package/dist/embed/index-DGhgtnaK.js +0 -577
  117. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  118. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  119. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  120. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  121. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -7,6 +7,15 @@ import {
7
7
  type RawCapabilities,
8
8
  type ServerCapabilities,
9
9
  } from "./server-capabilities";
10
+ import type {
11
+ PermissionCheck,
12
+ PermissionDecision,
13
+ PermissionResource,
14
+ } from "../permission-gate";
15
+ import type { ClusterStatus, ReplicationStatus } from "./cluster-status";
16
+ import type { MetricDescriptor, PromResponse } from "./analytics";
17
+ import type { AskResponse, AskOptions } from "./ask";
18
+ import { devConsole, sanitizePayload } from "./dev-console";
10
19
 
11
20
  export interface QueryRow {
12
21
  values: Record<string, unknown>;
@@ -104,59 +113,6 @@ export interface Stats {
104
113
  read_only_reason?: string;
105
114
  }
106
115
 
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
116
  export interface Whoami {
161
117
  ok: boolean;
162
118
  authenticated: boolean;
@@ -199,6 +155,12 @@ export interface AuthPolicy {
199
155
  created_at?: number;
200
156
  }
201
157
 
158
+ interface AuthCanResponse {
159
+ ok?: boolean;
160
+ results?: Array<Partial<PermissionDecision>>;
161
+ error?: string;
162
+ }
163
+
202
164
  export interface ChangeEvent {
203
165
  lsn: number;
204
166
  timestamp: number;
@@ -280,6 +242,18 @@ function unsupportedCollectionMetadataError(baseUrl: string): Error {
280
242
  );
281
243
  }
282
244
 
245
+ function isPermissionResource(value: unknown): value is PermissionResource {
246
+ if (!value || typeof value !== "object") return false;
247
+ const candidate = value as Partial<PermissionResource>;
248
+ return (
249
+ typeof candidate.kind === "string" &&
250
+ candidate.kind.length > 0 &&
251
+ typeof candidate.name === "string" &&
252
+ candidate.name.length > 0 &&
253
+ (candidate.tenant === undefined || typeof candidate.tenant === "string")
254
+ );
255
+ }
256
+
283
257
  /** One NDJSON frame from `POST /query/stream`. Discriminated by its single key. */
284
258
  type NdjsonFrame =
285
259
  | { descriptor: { columns: string[]; schema_fingerprint?: string } }
@@ -320,6 +294,46 @@ async function* parseNdjsonFrames(
320
294
 
321
295
  const SELECT_RE = /^\s*select\b/i;
322
296
 
297
+ /**
298
+ * Pull a row count out of a parsed response for the developer console (#128).
299
+ * Recognises the `/query` envelope (`record_count`, else the length of
300
+ * `result.records`) and a bare array; returns `undefined` when the shape
301
+ * carries no notion of rows. Purely structural — never throws.
302
+ */
303
+ export function extractRowCount(data: unknown): number | undefined {
304
+ if (Array.isArray(data)) return data.length;
305
+ if (data && typeof data === "object") {
306
+ const d = data as {
307
+ record_count?: unknown;
308
+ result?: { records?: unknown };
309
+ };
310
+ if (typeof d.record_count === "number") return d.record_count;
311
+ if (Array.isArray(d.result?.records)) return d.result.records.length;
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ /**
317
+ * Extract a human-readable message from any thrown value. Tauri's `invoke` and
318
+ * the HTTP plugin reject with plain strings or bare objects (not `Error`), so
319
+ * `(e as Error).message` is often `undefined` — this never returns that.
320
+ */
321
+ export function errorText(e: unknown): string {
322
+ if (e instanceof Error) return e.message || String(e);
323
+ if (typeof e === "string") return e;
324
+ if (e && typeof e === "object") {
325
+ const m = (e as { message?: unknown }).message;
326
+ if (typeof m === "string" && m) return m;
327
+ try {
328
+ const s = JSON.stringify(e);
329
+ if (s && s !== "{}") return s;
330
+ } catch {
331
+ /* circular — fall through */
332
+ }
333
+ }
334
+ return String(e);
335
+ }
336
+
323
337
  /**
324
338
  * Run a query, preferring the bounded-memory NDJSON stream for a plain SELECT
325
339
  * and falling back to the buffered `POST /query` for everything else — and for
@@ -334,7 +348,9 @@ export async function runQueryPreferStream(
334
348
  sql: string,
335
349
  opts: { maxRows?: number; signal?: AbortSignal } = {}
336
350
  ): Promise<QueryResult> {
337
- if (!SELECT_RE.test(sql)) return client.query(sql);
351
+ const queryOpts = opts.signal ? { signal: opts.signal } : undefined;
352
+ if (!SELECT_RE.test(sql))
353
+ return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
338
354
  try {
339
355
  const s = await client.queryStreamCollect(sql, opts);
340
356
  return {
@@ -348,8 +364,14 @@ export async function runQueryPreferStream(
348
364
  streamed: true,
349
365
  truncated: s.truncated,
350
366
  };
351
- } catch {
352
- return client.query(sql);
367
+ } catch (e) {
368
+ if (
369
+ opts.signal?.aborted ||
370
+ (e instanceof Error && e.name === "AbortError")
371
+ ) {
372
+ throw e;
373
+ }
374
+ return queryOpts ? client.query(sql, queryOpts) : client.query(sql);
353
375
  }
354
376
  }
355
377
 
@@ -372,14 +394,66 @@ export class RedClient {
372
394
  Object.assign(headers, this.headers as Record<string, string> | undefined);
373
395
  Object.assign(headers, init?.headers as Record<string, string> | undefined);
374
396
 
375
- const res = await this.fetcher(url, { ...init, headers });
397
+ // Developer console instrumentation (#128). Every HTTP call the client
398
+ // makes is timed and logged from this single seam, so every Surface is
399
+ // covered without touching each caller. The request body is sanitized
400
+ // before it lands in an entry — a copy action can never leak a secret.
401
+ const verb = init?.method ?? "GET";
402
+ const startedAt = Date.now();
403
+ const start = performance.now();
404
+ const payload = sanitizePayload(init?.body);
405
+ const elapsed = () => Math.round(performance.now() - start);
406
+
407
+ let res: Response;
408
+ try {
409
+ res = await this.fetcher(url, { ...init, headers });
410
+ } catch (e) {
411
+ devConsole.record({
412
+ kind: "http",
413
+ verb,
414
+ target: path,
415
+ startedAt,
416
+ durationMs: elapsed(),
417
+ ok: false,
418
+ payload,
419
+ error: errorText(e),
420
+ });
421
+ throw e;
422
+ }
423
+
376
424
  if (!res.ok) {
377
425
  const body = await res.text().catch(() => "");
378
- throw new Error(
379
- `${init?.method ?? "GET"} ${path} → ${res.status} ${body.slice(0, 200)}`
380
- );
426
+ const message = `${verb} ${path} → ${res.status} ${body.slice(0, 200)}`;
427
+ devConsole.record({
428
+ kind: "http",
429
+ verb,
430
+ target: path,
431
+ startedAt,
432
+ durationMs: elapsed(),
433
+ ok: false,
434
+ status: res.status,
435
+ payload,
436
+ error: message,
437
+ });
438
+ throw new Error(message);
381
439
  }
382
- return res.json() as Promise<T>;
440
+
441
+ const data = (await res.json()) as T;
442
+ const rowCount = extractRowCount(data);
443
+ devConsole.record({
444
+ // A response carrying a row count is a query round-trip; everything else
445
+ // is a plain HTTP call. This is how `/query` entries mark themselves.
446
+ kind: rowCount === undefined ? "http" : "query",
447
+ verb,
448
+ target: path,
449
+ startedAt,
450
+ durationMs: elapsed(),
451
+ ok: true,
452
+ status: res.status,
453
+ ...(rowCount === undefined ? {} : { rowCount }),
454
+ payload,
455
+ });
456
+ return data;
383
457
  }
384
458
 
385
459
  async stats(): Promise<Stats> {
@@ -390,6 +464,60 @@ export class RedClient {
390
464
  return this.json<ClusterStatus>("/cluster/status");
391
465
  }
392
466
 
467
+ /**
468
+ * List registered metric descriptors from the typed `red.metrics`
469
+ * projection (reddb #747). One row per `CREATE METRIC`, carrying the
470
+ * `kind` (counter|gauge|histogram|ratio|derived) the UI uses to auto-select
471
+ * a chart. Returns `[]` when the server has no analytics surface.
472
+ */
473
+ async metricsCatalog(): Promise<MetricDescriptor[]> {
474
+ const r = await this.query(
475
+ "SELECT path, kind, role, unit, supports_prometheus_query, created_at_ms FROM red.metrics"
476
+ );
477
+ return r.result.records.map((rec) => {
478
+ const v = rec.values;
479
+ return {
480
+ path: String(v.path ?? ""),
481
+ kind: v.kind as MetricDescriptor["kind"],
482
+ role: String(v.role ?? "metric"),
483
+ unit: (v.unit as string | null) ?? null,
484
+ supports_prometheus_query: v.supports_prometheus_query !== false,
485
+ created_at_ms:
486
+ typeof v.created_at_ms === "number" ? v.created_at_ms : undefined,
487
+ } satisfies MetricDescriptor;
488
+ });
489
+ }
490
+
491
+ /**
492
+ * Prometheus instant query (`GET /api/v1/query`). Returns the raw
493
+ * Prometheus-shaped envelope; callers normalise with `parsePromVector`.
494
+ */
495
+ async promQuery(query: string, time?: number): Promise<PromResponse> {
496
+ const qs = new URLSearchParams({ query });
497
+ if (time !== undefined) qs.set("time", String(time));
498
+ return this.json<PromResponse>(`/api/v1/query?${qs}`);
499
+ }
500
+
501
+ /**
502
+ * Prometheus range query (`GET /api/v1/query_range`). `start`/`end` are unix
503
+ * seconds, `step` is the resolution in seconds. Normalise with
504
+ * `parsePromMatrix`.
505
+ */
506
+ async promQueryRange(
507
+ query: string,
508
+ start: number,
509
+ end: number,
510
+ step: number
511
+ ): Promise<PromResponse> {
512
+ const qs = new URLSearchParams({
513
+ query,
514
+ start: String(start),
515
+ end: String(end),
516
+ step: String(step),
517
+ });
518
+ return this.json<PromResponse>(`/api/v1/query_range?${qs}`);
519
+ }
520
+
393
521
  async ping(): Promise<
394
522
  { ok: true; rtt_ms: number } | { ok: false; error: string }
395
523
  > {
@@ -398,7 +526,7 @@ export class RedClient {
398
526
  await this.json("/stats");
399
527
  return { ok: true, rtt_ms: Math.round(performance.now() - start) };
400
528
  } catch (e) {
401
- return { ok: false, error: (e as Error).message };
529
+ return { ok: false, error: errorText(e) };
402
530
  }
403
531
  }
404
532
 
@@ -497,10 +625,14 @@ export class RedClient {
497
625
  );
498
626
  }
499
627
 
500
- async query(query: string): Promise<QueryResult> {
628
+ async query(
629
+ query: string,
630
+ opts: { signal?: AbortSignal } = {}
631
+ ): Promise<QueryResult> {
501
632
  return this.json<QueryResult>("/query", {
502
633
  method: "POST",
503
634
  body: JSON.stringify({ query }),
635
+ signal: opts.signal,
504
636
  });
505
637
  }
506
638
 
@@ -528,17 +660,51 @@ export class RedClient {
528
660
  };
529
661
  Object.assign(headers, this.headers as Record<string, string> | undefined);
530
662
 
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
- });
663
+ // Developer console instrumentation (#128). The streaming endpoint skips
664
+ // `json()`, so it logs its own query round-trip here (verb `POST`, target
665
+ // `/query/stream`) with the collected row count.
666
+ const startedAt = Date.now();
667
+ const start = performance.now();
668
+ const body = JSON.stringify({ query });
669
+ const payload = sanitizePayload(body);
670
+ const elapsed = () => Math.round(performance.now() - start);
671
+
672
+ let res: Response;
673
+ try {
674
+ res = await this.fetcher(`${this.baseUrl}/query/stream`, {
675
+ method: "POST",
676
+ headers,
677
+ body,
678
+ signal: opts.signal,
679
+ });
680
+ } catch (e) {
681
+ devConsole.record({
682
+ kind: "query",
683
+ verb: "POST",
684
+ target: "/query/stream",
685
+ startedAt,
686
+ durationMs: elapsed(),
687
+ ok: false,
688
+ payload,
689
+ error: errorText(e),
690
+ });
691
+ throw e;
692
+ }
537
693
  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
- );
694
+ const errBody = res.body ? await res.text().catch(() => "") : "";
695
+ const message = `POST /query/stream → ${res.status} ${errBody.slice(0, 200)}`;
696
+ devConsole.record({
697
+ kind: "query",
698
+ verb: "POST",
699
+ target: "/query/stream",
700
+ startedAt,
701
+ durationMs: elapsed(),
702
+ ok: false,
703
+ status: res.status,
704
+ payload,
705
+ error: message,
706
+ });
707
+ throw new Error(message);
542
708
  }
543
709
 
544
710
  const out: StreamedQueryResult = {
@@ -566,6 +732,18 @@ export class RedClient {
566
732
  }
567
733
  }
568
734
 
735
+ devConsole.record({
736
+ kind: "query",
737
+ verb: "POST",
738
+ target: "/query/stream",
739
+ startedAt,
740
+ durationMs: elapsed(),
741
+ ok: true,
742
+ status: res.status,
743
+ rowCount: out.rowCount,
744
+ payload,
745
+ });
746
+
569
747
  // We stopped early with more rows on the server — release the pinned cursor.
570
748
  if (out.truncated && out.cursor) {
571
749
  await this.cancelQueryStream(out.cursor).catch(() => {
@@ -654,6 +832,20 @@ export class RedClient {
654
832
  return this.json<Whoami>("/auth/whoami");
655
833
  }
656
834
 
835
+ /**
836
+ * Grounded natural-language query (`POST /ai/ask`, ADR 0013). Returns the
837
+ * synthesised answer plus the `sources_flat` it was grounded on and the
838
+ * `[^N]` citations linking claims to sources. Requires the server's AI
839
+ * provider to be configured; an unconfigured server answers with an error
840
+ * status (surfaced by `json()`).
841
+ */
842
+ async ask(question: string, opts: AskOptions = {}): Promise<AskResponse> {
843
+ return this.json<AskResponse>("/ai/ask", {
844
+ method: "POST",
845
+ body: JSON.stringify({ question, ...opts }),
846
+ });
847
+ }
848
+
657
849
  async users(): Promise<AuthUser[]> {
658
850
  const r = await this.json<{ ok: boolean; users: AuthUser[] }>(
659
851
  "/auth/users"
@@ -675,6 +867,33 @@ export class RedClient {
675
867
  return r.policies ?? [];
676
868
  }
677
869
 
870
+ async authCan(checks: PermissionCheck[]): Promise<PermissionDecision[]> {
871
+ const r = await this.json<AuthCanResponse>("/auth/can", {
872
+ method: "POST",
873
+ body: JSON.stringify({ checks }),
874
+ });
875
+ if (!Array.isArray(r.results)) {
876
+ throw new Error(r.error ?? "POST /auth/can returned no results");
877
+ }
878
+ return checks.map((check, index) => {
879
+ const result = r.results?.[index] ?? {};
880
+ const resource = isPermissionResource(result.resource)
881
+ ? result.resource
882
+ : check.resource;
883
+ return {
884
+ action:
885
+ typeof result.action === "string" ? result.action : check.action,
886
+ resource,
887
+ current_tenant:
888
+ typeof result.current_tenant === "string"
889
+ ? result.current_tenant
890
+ : check.current_tenant,
891
+ allowed: result.allowed === true,
892
+ reason: typeof result.reason === "string" ? result.reason : undefined,
893
+ };
894
+ });
895
+ }
896
+
678
897
  async changes(
679
898
  sinceLsn?: number,
680
899
  opts: { collection?: string; limit?: number } = {}