File size: 6,063 Bytes
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// ---------------------------------------------------------------------------
// query/queryApi.ts β€” WAVE 32 item 7 (ruling R1, contract C5): the four calls,
// and nothing else.
//
// Shaped like `alerts/alertsApi.ts` β€” the same `Result<T>`, the same 4xx/5xx
// message split β€” because the failure that matters is identical: a wrong
// `credentials` word produces no client-side symptom at all, just a 401 from a
// server that never saw a session.
//
// β›” A REFUSAL IS A 200 HERE, AND THAT IS THE CONTRACT, NOT A LOOSE END. C5's
// promise is that a question the backend cannot express gets "a plain sentence,
// not a broken view", so `build` answers `{spec: null, refused: "<sentence>"}`
// with an OK status. Mapping it onto an error would paint the error page the
// ruling exists to prevent. The 4xx cases are the ones that really are the
// caller's fault: no database named, no question, a database this session may
// not open.
//
// ⚠ NO CLIENT UNION OVER `kind`. It is the server's vocabulary and arrives as a
// string (the wave-9 law): a client `type Kind = "grid" | …` turns "the server
// added a kind" into "the client drops the view".
// ---------------------------------------------------------------------------

import { API_V1, CREDENTIALS } from "../apiContract";

export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; status: number; message: string };

/** One saved AI-built view, as `GET /query` sends it. */
export interface SavedQuery {
  id: string;
  scope: string;
  viewId: string;
  name: string;
  /** A STRING, never a union β€” see the header. */
  kind: string;
  question: string;
  explain: string;
  createdAt?: string;
}

/** What `POST /query/build` answers. Exactly one of `spec` / `refused` is set. */
export interface BuildResult {
  spec: unknown | null;
  explain: string | null;
  refused: string | null;
  /** The machine-readable cause behind `refused`, when the server names one. */
  reason?: string;
  id?: string;
}

/** 4xx text is POLICY the reader needs ("this database does not have …"); a 5xx's

 *  text is the server's internals and is never shown. Same split as alertsApi. */
export function errorMessage(status: number, message?: string): string {
  if (status >= 500 || !message) {
    return status >= 500
      ? "Something went wrong on our side. Try again in a moment."
      : `The server answered ${status}.`;
  }
  return message;
}

async function call<T>(
  path: string,
  init: RequestInit,
  read: (body: unknown) => T
): Promise<Result<T>> {
  let res: Response;
  try {
    res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
  } catch {
    return { ok: false, status: 0, message: "Cannot reach the server." };
  }
  const body = (await res.json().catch(() => null)) as unknown;
  if (!res.ok) {
    const detail = (body as { error?: { message?: string } } | null)?.error?.message;
    return { ok: false, status: res.status, message: errorMessage(res.status, detail) };
  }
  return { ok: true, value: read(body) };
}

const json = (data: unknown): RequestInit => ({
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(data),
});

const str = (v: unknown): string => (typeof v === "string" ? v : "");

/** Fails CLOSED: an unreadable answer is an empty list, never a half-parsed one. */
export function parseSaved(body: unknown): SavedQuery[] {
  const rows = (body as { views?: unknown[] } | null)?.views;
  if (!Array.isArray(rows)) return [];
  return rows
    .map((r) => r as Record<string, unknown>)
    .filter((r) => r && str(r.id) && str(r.scope))
    .map((r) => ({
      id: str(r.id),
      scope: str(r.scope),
      viewId: str(r.viewId) || str(r.id),
      name: str(r.name) || "Query",
      kind: str(r.kind) || "grid",
      question: str(r.question),
      explain: str(r.explain),
      createdAt: str(r.createdAt) || undefined,
    }));
}

/** `GET /query`: the saved views AND the built-in scopes the build door accepts. */
export interface QueryIndex {
  views: SavedQuery[];
  /**

   * The non-`ut_` scopes `POST /query/build` will accept, published by the server.

   *

   * β›” THE PICKER MIRRORS THIS INSTEAD OF GUESSING. The nav hands this page every granted entry,

   * which is a WIDER set than the build door accepts β€” a picker filtered on "not a surface" alone

   * offers databases that 404. `view_templates`' rule: a picker that offers what the door would

   * refuse is a control that lies.

   */
  builtins: string[];
}

export function fetchQueries(): Promise<Result<QueryIndex>> {
  return call("/query", {}, (b) => ({
    views: parseSaved(b),
    builtins: (((b as { builtins?: unknown } | null)?.builtins as unknown[]) ?? [])
      .filter((k): k is string => typeof k === "string"),
  }));
}

/**

 * Ask for a view. **This writes nothing** β€” the spec comes back for a person to

 * read before `saveQuery` stores it, which is the only check on the one failure

 * class no validator can see (a well-formed spec that answers a different

 * question). See `routes_query.py`'s header.

 */
export function buildQuery(question: string, scope: string): Promise<Result<BuildResult>> {
  return call("/query/build", json({ question, scope }), (b) => {
    const r = (b || {}) as Record<string, unknown>;
    return {
      spec: r.spec ?? null,
      explain: str(r.explain) || null,
      refused: str(r.refused) || null,
      reason: str(r.reason) || undefined,
      id: str(r.id) || undefined,
    };
  });
}

export function saveQuery(

  question: string,

  scope: string,

  spec: unknown

): Promise<Result<SavedQuery>> {
  return call("/query/save", json({ question, scope, spec }), (b) => parseSaved({ views: [b] })[0]);
}

export function deleteQuery(id: string): Promise<Result<true>> {
  return call(`/query/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
}