@m6d/cortex-cli 0.17.0 → 0.19.0

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/README.md CHANGED
@@ -225,6 +225,39 @@ or any name such a collision decided (`ambiguous_tool`), since a path the
225
225
  document adds or drops could move it to another endpoint. It also fails when an
226
226
  operation's name is already a listed tool's or another source's.
227
227
 
228
+ A collection lists its documents. Each is inline `text`, a file `path`
229
+ relative to the manifest file, or a `url` Cortex reads on every apply, with no
230
+ credentials:
231
+
232
+ ```ts
233
+ collections: [
234
+ {
235
+ name: "Policies",
236
+ audience: { agents: ["support"] },
237
+ documents: [
238
+ { name: "faq.md", text: "# FAQ\n\nReturns take 30 days." },
239
+ { path: "./docs/returns.pdf" },
240
+ { url: "https://shop.example.com/policies/shipping.html" },
241
+ ],
242
+ // overridable: ["documents"],
243
+ },
244
+ ],
245
+ ```
246
+
247
+ A document's name sets its type: `.md`, `.txt`, `.html`, `.htm`, `.pdf` or
248
+ `.docx`. A path or URL document is named after its file unless `name` says
249
+ otherwise. Each is at most 10 MB. `cortex apply` reads paths and sends their
250
+ bytes, so a request carries every inline and path document again; Cortex
251
+ stores only what changed. A document with new bytes replaces the old one,
252
+ which keeps answering until the new one is processed, and one the manifest
253
+ drops is retired.
254
+
255
+ Listing `documents` makes the manifest own them: an apply retires a document
256
+ uploaded in the console, the first apply after upgrading included, unless
257
+ the collection offers `documents` in `overridable`. Offered, a document
258
+ uploaded or removed in Cortex stays so until the manifest changes that same
259
+ document, which `cortex apply` then asks about.
260
+
228
261
  The manifest is checked against the contract before anything is sent, then
229
262
  sent as written, so Cortex fills its own defaults. It goes to the
230
263
  management API (`manifests/sync`, or `manifests/preview` with `--dry-run`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-cli",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Scaffold Cortex projects and manage a Cortex deployment",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -4,6 +4,11 @@ import { relative, resolve } from "node:path";
4
4
  import { Option } from "@commander-js/extra-typings";
5
5
  import type { Command } from "@commander-js/extra-typings";
6
6
  import { isRecord } from "@cortex/contracts/json";
7
+ import {
8
+ callManagement,
9
+ ManagementClientError,
10
+ managementPath,
11
+ } from "@cortex/contracts/management";
7
12
  import {
8
13
  appliedManifestSchema,
9
14
  manifestApplyInputSchema,
@@ -12,7 +17,7 @@ import {
12
17
  import { loadManifestFile } from "@cortex/contracts/manifest-file";
13
18
  import { z } from "zod";
14
19
 
15
- import { callManagement, ManagementError } from "@/management/client";
20
+ import { managementTarget } from "@/management/client";
16
21
  import { callOrFail, confirmDestructive, requireProfile } from "@/commands/management";
17
22
  import { askChoice, canAsk } from "@/ui/prompts";
18
23
  import * as report from "@/ui/report";
@@ -137,7 +142,7 @@ export async function applyManifest(options: {
137
142
  const { profile, dryRun } = options;
138
143
  const answer = await sendManifest({
139
144
  ...options,
140
- path: dryRun ? "/manifests/preview" : "/manifests/sync",
145
+ path: managementPath(dryRun ? "manifests.preview" : "manifests.sync"),
141
146
  });
142
147
  // The secrets are read apart from the changes: a sync has committed by
143
148
  // now, and a summary this CLI is too old to read must not lose them.
@@ -179,14 +184,14 @@ async function sendManifest(options: {
179
184
  const resolutions: Resolution = [];
180
185
  for (;;) {
181
186
  try {
182
- return await callManagement(profile, "post", path, {
183
- ...manifest,
184
- prefer,
185
- resolutions,
187
+ return await callManagement(managementTarget(profile), {
188
+ method: "post",
189
+ path,
190
+ input: { ...manifest, prefer, resolutions },
186
191
  });
187
192
  } catch (error) {
188
193
  const refused =
189
- error instanceof ManagementError
194
+ error instanceof ManagementClientError
190
195
  ? unresolvedSchema.safeParse(error.detail)
191
196
  : undefined;
192
197
  if (!refused?.success) throw error;
@@ -202,9 +207,14 @@ async function sendManifest(options: {
202
207
  }
203
208
  }
204
209
 
210
+ /** The field both sides changed, and its part when the field is judged part by part. */
211
+ function conflictField({ field, key }: Conflict) {
212
+ return key === undefined ? field : `${field} (${key})`;
213
+ }
214
+
205
215
  /** How a conflict reads: its entry and the field both sides changed. */
206
- function conflictLabel({ kind, name, field }: Conflict) {
207
- return `${kind} ${name}: ${field} changed in both places`;
216
+ function conflictLabel(conflict: Conflict) {
217
+ return `${conflict.kind} ${conflict.name}: ${conflictField(conflict)} changed in both places`;
208
218
  }
209
219
 
210
220
  /** Ends a run that does not ask, naming every conflict and how to run `command` again to answer them. */
@@ -250,8 +260,9 @@ export function findManifest(path: string, command?: string) {
250
260
 
251
261
  /**
252
262
  * The file's default export, checked against the manifest contract here so
253
- * a mistake is named before anything is sent, then sent as written: Cortex
254
- * fills its own defaults, which may be newer than this CLI's.
263
+ * a mistake is named before anything is sent, then sent as written, each
264
+ * document `path` read from beside the file: Cortex fills its own defaults,
265
+ * which may be newer than this CLI's.
255
266
  */
256
267
  export async function loadManifest(path: string, verbose: boolean) {
257
268
  const { written, parsed } = await loadManifestFile(path).catch((error: unknown) =>
@@ -288,8 +299,12 @@ function printChanges(
288
299
  rows.set(kind, [...(rows.get(kind) ?? []), ...marked]);
289
300
  }
290
301
  }
291
- for (const { kind, name, field } of conflicts) {
292
- rows.set(kind, [...(rows.get(kind) ?? []), `! ${name} ${field} changed in both places`]);
302
+ for (const conflict of conflicts) {
303
+ const { kind, name } = conflict;
304
+ rows.set(kind, [
305
+ ...(rows.get(kind) ?? []),
306
+ `! ${name} ${conflictField(conflict)} changed in both places`,
307
+ ]);
293
308
  }
294
309
  for (const [kind, lines] of rows) {
295
310
  for (const [index, line] of lines.entries()) {
@@ -3,12 +3,13 @@ import { readFileSync } from "node:fs";
3
3
  import { type Command, Option } from "@commander-js/extra-typings";
4
4
  import { canCarrySecret } from "@cortex/contracts/egress-policy";
5
5
  import { isRecord } from "@cortex/contracts/json";
6
+ import { callManagement, ManagementClientError } from "@cortex/contracts/management";
6
7
 
7
8
  import {
8
- callManagement,
9
9
  inputSchemaOf,
10
10
  loadOpenApi,
11
- ManagementError,
11
+ managementClient,
12
+ managementTarget,
12
13
  UnreachableError,
13
14
  } from "@/management/client";
14
15
  import type { OpenApiDocument, OpenApiOperation } from "@/management/client";
@@ -134,14 +135,11 @@ async function login(given: string | undefined, name: string) {
134
135
  */
135
136
  async function whoami(profile: { url: string; key: string }) {
136
137
  try {
137
- const me = (await callManagement(profile, "get", "/management-keys/mine", undefined)) as {
138
- name: string;
139
- fingerprint: string;
140
- };
138
+ const me = await managementClient(profile).managementKeys.mine();
141
139
  return `key "${me.name}" (${me.fingerprint})`;
142
140
  } catch (error) {
143
- if (error instanceof ManagementError && error.status === 401) return null;
144
- if (error instanceof ManagementError) {
141
+ if (error instanceof ManagementClientError && error.status === 401) return null;
142
+ if (error instanceof ManagementClientError) {
145
143
  report.fail({
146
144
  what: `${profile.url} answered ${error.status} ${error.code}: ${error.message}`,
147
145
  });
@@ -323,7 +321,7 @@ function registerOperation(scope: {
323
321
  const result = await callOrFail(profile, () =>
324
322
  all
325
323
  ? walkPages(profile, method, path, body)
326
- : callManagement(profile, method, path, body),
324
+ : callManagement(managementTarget(profile), { method, path, input: body }),
327
325
  );
328
326
  print(result, format);
329
327
  if (publisher) {
@@ -427,7 +425,11 @@ async function publishAfterWrite(
427
425
  }
428
426
  if (publish) {
429
427
  const published = await callOrFail(profile, () =>
430
- callManagement(profile, publisher.method, publisher.path, { id }),
428
+ callManagement(managementTarget(profile), {
429
+ method: publisher.method,
430
+ path: publisher.path,
431
+ input: { id },
432
+ }),
431
433
  );
432
434
  options.note(`Published ${id}.`);
433
435
  for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
@@ -454,7 +456,7 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
454
456
  * rethrown.
455
457
  */
456
458
  export function failCall(profile: { url: string }, error: unknown): never {
457
- if (error instanceof ManagementError) {
459
+ if (error instanceof ManagementClientError) {
458
460
  report.fail({
459
461
  what: `${error.status} ${error.code}: ${error.message}`,
460
462
  blocks: [
@@ -605,10 +607,10 @@ export async function walkPages(
605
607
  // Bounded: if Cortex reports more rows than it serves, the walk must not
606
608
  // spin, and a listing the bound cannot cover is an error, never a short result.
607
609
  for (let pageIndex = 0; pageIndex < MAX_PAGES; pageIndex += 1) {
608
- const page = (await callManagement(profile, method, path, {
609
- ...given,
610
- pageIndex,
611
- pageSize,
610
+ const page = (await callManagement(managementTarget(profile), {
611
+ method,
612
+ path,
613
+ input: { ...given, pageIndex, pageSize },
612
614
  })) as { items?: unknown[]; count?: number; filteredCount?: number };
613
615
  if (!Array.isArray(page.items)) return page;
614
616
  items.push(...page.items);
@@ -12,6 +12,7 @@ import {
12
12
  normalizeHost,
13
13
  scrubCredentialStrings,
14
14
  } from "@cortex/contracts/egress-policy";
15
+ import { ManagementClientError, managementPath } from "@cortex/contracts/management";
15
16
  import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH, SETTING_BOUNDS } from "@cortex/contracts/manifest";
16
17
  import { z } from "zod";
17
18
 
@@ -29,7 +30,7 @@ import {
29
30
  requireProfile,
30
31
  walkPages,
31
32
  } from "@/commands/management";
32
- import { callManagement, ManagementError, UnreachableError } from "@/management/client";
33
+ import { managementClient, UnreachableError } from "@/management/client";
33
34
  import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
34
35
  import * as prompts from "@/ui/prompts";
35
36
  import * as report from "@/ui/report";
@@ -154,7 +155,7 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
154
155
  interactive,
155
156
  command: "bun cortex apply --yes",
156
157
  }).catch(function (error: unknown) {
157
- if (error instanceof ManagementError && error.message === "chat_model_required") {
158
+ if (error instanceof ManagementClientError && error.message === "chat_model_required") {
158
159
  return undefined;
159
160
  }
160
161
  return failCall(profile, error);
@@ -233,12 +234,12 @@ async function waitForCortex(profile: Profile, wait: boolean) {
233
234
  let announced = false;
234
235
  for (;;) {
235
236
  try {
236
- await callManagement(profile, "get", "/secrets/list", { pageSize: 1 });
237
+ await managementClient(profile).secrets.list({ pageSize: 1 });
237
238
  return;
238
239
  } catch (error) {
239
240
  const starting =
240
241
  error instanceof UnreachableError ||
241
- (error instanceof ManagementError && error.status >= 500);
242
+ (error instanceof ManagementClientError && error.status >= 500);
242
243
  if (!starting || Date.now() >= deadline) failCall(profile, error);
243
244
  if (!announced) {
244
245
  report.row("", `Waiting for ${profile.url}. A first start pulls the image.`);
@@ -249,24 +250,21 @@ async function waitForCortex(profile: Profile, wait: boolean) {
249
250
  }
250
251
  }
251
252
 
252
- function call(profile: Profile, method: "get" | "post", path: string, input: unknown) {
253
- return callOrFail(profile, () => callManagement(profile, method, path, input));
254
- }
255
-
256
253
  /** The entry named exactly `name`, across every page; a listing's search matches any part of it. */
257
254
  async function findByName<TItem extends typeof namedSchema>(
258
255
  profile: Profile,
259
- router: string,
256
+ router: "secrets" | "modelProviders" | "servers",
260
257
  name: string,
261
258
  item: TItem,
262
259
  ) {
263
- const { items } = z
264
- .object({ items: z.array(item) })
265
- .parse(
266
- await callOrFail(profile, () =>
267
- walkPages(profile, "get", `/${router}/list`, { q: name, pageSize: 100 }),
268
- ),
269
- );
260
+ const { items } = z.object({ items: z.array(item) }).parse(
261
+ await callOrFail(profile, () =>
262
+ walkPages(profile, "get", managementPath(`${router}.list`), {
263
+ q: name,
264
+ pageSize: 100,
265
+ }),
266
+ ),
267
+ );
270
268
  return items.find((entry) => entry.name === name);
271
269
  }
272
270
 
@@ -284,7 +282,9 @@ function secretFor(owner: string, role: "api-key" | "server-key", value: string)
284
282
 
285
283
  async function ensureSecret(profile: Profile, name: string, value: string) {
286
284
  const found = await findByName(profile, "secrets", name, namedSchema);
287
- if (!found) await call(profile, "post", "/secrets/set", { name, value });
285
+ if (!found) {
286
+ await callOrFail(profile, () => managementClient(profile).secrets.set({ name, value }));
287
+ }
288
288
  }
289
289
 
290
290
  /**
@@ -307,7 +307,7 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
307
307
  * write.
308
308
  */
309
309
  async function ensureProvider(profile: Profile, provider: Provider) {
310
- const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
310
+ const found = await findByName(profile, "modelProviders", provider.name, providerRowSchema);
311
311
  if (found && found.baseUrl !== provider.baseUrl) {
312
312
  failCallsElsewhere(
313
313
  `Provider ${provider.name}`,
@@ -330,13 +330,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
330
330
  await ensureSecret(profile, secretName, provider.apiKey);
331
331
 
332
332
  if (!found) {
333
- await call(profile, "post", "/model-providers/create", {
334
- name: provider.name,
335
- baseUrl: provider.baseUrl,
336
- secretName,
337
- models,
338
- efforts: [],
339
- });
333
+ await callOrFail(profile, () =>
334
+ managementClient(profile).modelProviders.create({
335
+ name: provider.name,
336
+ baseUrl: provider.baseUrl,
337
+ secretName,
338
+ models,
339
+ efforts: [],
340
+ }),
341
+ );
340
342
  report.input("Provider", `${provider.name} · created`);
341
343
  return;
342
344
  }
@@ -344,13 +346,15 @@ async function ensureProvider(profile: Profile, provider: Provider) {
344
346
  report.input("Provider", `${provider.name} · kept`);
345
347
  return;
346
348
  }
347
- await call(profile, "post", "/model-providers/update", {
348
- id: found.id,
349
- baseUrl: found.baseUrl,
350
- secretName,
351
- models,
352
- efforts: found.efforts,
353
- });
349
+ await callOrFail(profile, () =>
350
+ managementClient(profile).modelProviders.update({
351
+ id: found.id,
352
+ baseUrl: found.baseUrl,
353
+ secretName,
354
+ models,
355
+ efforts: found.efforts,
356
+ }),
357
+ );
354
358
  report.input("Provider", `${provider.name} · updated`);
355
359
  }
356
360
 
@@ -369,7 +373,11 @@ async function ensureServer(
369
373
  const url = `${project.CORTEX_SERVER_URL}/cortex`;
370
374
  const found = await findByName(profile, "servers", name, namedSchema);
371
375
  const server = found
372
- ? serverSchema.parse(await call(profile, "get", "/servers/get", { id: found.id }))
376
+ ? serverSchema.parse(
377
+ await callOrFail(profile, () =>
378
+ managementClient(profile).servers.get({ id: found.id }),
379
+ ),
380
+ )
373
381
  : undefined;
374
382
  if (server && server.url !== url) {
375
383
  failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
@@ -384,15 +392,17 @@ async function ensureServer(
384
392
  };
385
393
  if (!server) {
386
394
  const created = serverSchema.parse(
387
- await call(profile, "post", "/servers/create", {
388
- name,
389
- description: "",
390
- url,
391
- keySecretName,
392
- ...model,
393
- signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
394
- triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
395
- }),
395
+ await callOrFail(profile, () =>
396
+ managementClient(profile).servers.create({
397
+ name,
398
+ description: "",
399
+ url,
400
+ keySecretName,
401
+ ...model,
402
+ signInChecksPerMinute: SETTING_BOUNDS.server.signInChecksPerMinute.default,
403
+ triggerChecksPerMinute: SETTING_BOUNDS.server.triggerChecksPerMinute.default,
404
+ }),
405
+ ),
396
406
  );
397
407
  report.input("Server", `${name} · created`);
398
408
  return created;
@@ -404,22 +414,24 @@ async function ensureServer(
404
414
  return server;
405
415
  }
406
416
  const updated = serverSchema.parse(
407
- await call(profile, "post", "/servers/update", {
408
- id: server.id,
409
- name,
410
- description: server.description ?? "",
411
- url,
412
- keySecretName,
413
- ...(takesModel
414
- ? model
415
- : {
416
- modelProvider: server.modelProvider,
417
- modelName: server.modelName,
418
- modelEffort: server.modelEffort,
419
- }),
420
- signInChecksPerMinute: server.signInChecksPerMinute,
421
- triggerChecksPerMinute: server.triggerChecksPerMinute,
422
- }),
417
+ await callOrFail(profile, () =>
418
+ managementClient(profile).servers.update({
419
+ id: server.id,
420
+ name,
421
+ description: server.description ?? "",
422
+ url,
423
+ keySecretName,
424
+ ...(takesModel
425
+ ? model
426
+ : {
427
+ modelProvider: server.modelProvider,
428
+ modelName: server.modelName,
429
+ modelEffort: server.modelEffort,
430
+ }),
431
+ signInChecksPerMinute: server.signInChecksPerMinute,
432
+ triggerChecksPerMinute: server.triggerChecksPerMinute,
433
+ }),
434
+ ),
423
435
  );
424
436
  report.input("Server", `${name} · updated`);
425
437
  return updated;
@@ -434,14 +446,14 @@ async function syncServer(profile: Profile, id: string, url: string) {
434
446
  const deadline = Date.now() + SYNC_WAIT_MS;
435
447
  for (;;) {
436
448
  try {
437
- await callManagement(profile, "post", "/servers/sync", { id });
449
+ await managementClient(profile).servers.sync({ id });
438
450
  report.input("Synced", `${url}/cortex`);
439
451
  return;
440
452
  } catch (error) {
441
453
  // Only a backend Cortex cannot reach yet is worth asking again; a
442
454
  // refused manifest or blocked host fails the same way next time.
443
455
  const starting =
444
- error instanceof ManagementError && error.message === "upstream_failed";
456
+ error instanceof ManagementClientError && error.message === "upstream_failed";
445
457
  if (starting && Date.now() < deadline) {
446
458
  await Bun.sleep(POLL_MS);
447
459
  continue;
@@ -57,8 +57,13 @@ type ParsedDatasetSql = {
57
57
  /** A refusal the model can act on. */
58
58
  export class DatasetSqlError extends Error {}
59
59
 
60
+ /**
61
+ * `upper` is a word's keyword spelling. A quoted identifier and a word after a
62
+ * dot (`plans.from`) have none (`""`), so no keyword check matches them and
63
+ * they always read as names.
64
+ */
60
65
  type Token =
61
- | { kind: "word"; value: string; upper: string; quoted?: true }
66
+ | { kind: "word"; value: string; upper: string }
62
67
  | { kind: "string"; value: string }
63
68
  | { kind: "number"; value: number }
64
69
  | { kind: "punct"; value: string }
@@ -84,6 +89,14 @@ function stringLiteral(sql: string, start: number) {
84
89
  }
85
90
  }
86
91
 
92
+ /** Whether `previous` ends a value, so a minus after it subtracts; after a keyword or an operator it signs the number. */
93
+ function endsValue(previous: Token | undefined) {
94
+ if (previous === undefined) return false;
95
+ if (previous.kind === "punct") return previous.value === ")";
96
+ if (previous.kind !== "word") return true;
97
+ return ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous);
98
+ }
99
+
87
100
  /** Reads the token at `i`: a quoted identifier, a number (negative only where a value is expected), a word or punctuation. */
88
101
  function lexeme(sql: string, i: number, previous: Token | undefined) {
89
102
  const char = sql[i]!;
@@ -92,27 +105,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
92
105
  if (close === -1) throw new DatasetSqlError("A quoted identifier is not closed.");
93
106
  const value = sql.slice(i + 1, close);
94
107
  return {
95
- token: {
96
- kind: "word",
97
- value,
98
- upper: value.toUpperCase(),
99
- quoted: true,
100
- } satisfies Token,
108
+ token: { kind: "word", value, upper: "" } satisfies Token,
101
109
  next: close + 1,
102
110
  };
103
111
  }
104
112
  const rest = sql.slice(i);
105
113
  const number = /^-?\d+(\.\d+)?/.exec(rest);
106
- // After a value or a closing parenthesis a minus subtracts; after a keyword or an operator it signs the number.
107
- const afterValue =
108
- previous !== undefined &&
109
- (previous.kind === "punct"
110
- ? previous.value === ")"
111
- : previous.kind === "word"
112
- ? ["END", "NULL", "TRUE", "FALSE"].includes(previous.upper) || isName(previous)
113
- : true);
114
- const signIsOperator = char === "-" && afterValue;
115
- if (number && !signIsOperator) {
114
+ if (number && !(char === "-" && endsValue(previous))) {
116
115
  return {
117
116
  token: { kind: "number", value: Number(number[0]) } satisfies Token,
118
117
  next: i + number[0].length,
@@ -120,8 +119,13 @@ function lexeme(sql: string, i: number, previous: Token | undefined) {
120
119
  }
121
120
  const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
122
121
  if (word) {
122
+ const qualified = previous?.kind === "punct" && previous.value === ".";
123
123
  return {
124
- token: { kind: "word", value: word[0], upper: word[0].toUpperCase() } satisfies Token,
124
+ token: {
125
+ kind: "word",
126
+ value: word[0],
127
+ upper: qualified ? "" : word[0].toUpperCase(),
128
+ } satisfies Token,
125
129
  next: i + word[0].length,
126
130
  };
127
131
  }
@@ -338,12 +342,35 @@ const RESERVED = [
338
342
  "END",
339
343
  ];
340
344
 
345
+ /** Whether a bare word reads as a keyword rather than a name. */
346
+ function isKeyword(upper: string) {
347
+ return RESERVED.includes(upper) || upper in REFUSED_CLAUSES;
348
+ }
349
+
341
350
  function isName(token: Token): token is Token & { kind: "word" } {
342
- if (token.kind !== "word") return false;
343
- return (
344
- token.quoted === true ||
345
- (!RESERVED.includes(token.upper) && !(token.upper in REFUSED_CLAUSES))
346
- );
351
+ return token.kind === "word" && !isKeyword(token.upper);
352
+ }
353
+
354
+ /** Words that bare read as a value rather than a column. */
355
+ const VALUE_WORDS = ["TRUE", "FALSE", "CURRENT_DATE", "CURRENT_TIMESTAMP"];
356
+
357
+ /**
358
+ * A column name as a query writes it: double quoted when bare it would read
359
+ * as a keyword or a value (`"from"`), so what the model is shown is what it
360
+ * can send.
361
+ */
362
+ export function sqlColumnName(name: string) {
363
+ const upper = name.toUpperCase();
364
+ return isKeyword(upper) || VALUE_WORDS.includes(upper) ? `"${name}"` : name;
365
+ }
366
+
367
+ /** `message`, and for a keyword where a column belongs, how to write a column spelled like it. */
368
+ function keywordError(token: Token, message: string) {
369
+ const hint =
370
+ token.kind === "word" && isKeyword(token.upper)
371
+ ? ` ${token.value} is a SQL keyword: write a column of that name as "${token.value}" or dataset.${token.value}.`
372
+ : "";
373
+ return new DatasetSqlError(`${message}${hint}`);
347
374
  }
348
375
 
349
376
  /** A unit written as `'month'`, `month` or `MONTH`. */
@@ -971,11 +998,21 @@ class Parser {
971
998
  );
972
999
  }
973
1000
  const op = (operator.value === "<>" ? "!=" : operator.value) as DatasetFilterOp;
1001
+ const message = `"${shown} ${operator.value}" needs a value on the right.`;
974
1002
  if (this.startsTerm()) {
975
- return { kind: "cond", left, op, value: { term: this.term("") } } satisfies Predicate;
1003
+ return {
1004
+ kind: "cond",
1005
+ left,
1006
+ op,
1007
+ value: { term: this.term(message) },
1008
+ } satisfies Predicate;
1009
+ }
1010
+ // A keyword here may be a column spelled like one (`x > from`); NULL keeps its own refusal.
1011
+ const next = this.peek();
1012
+ if (next.kind === "word" && next.upper !== "NULL" && isKeyword(next.upper)) {
1013
+ throw keywordError(next, message);
976
1014
  }
977
- const value = this.literal(`"${shown} ${operator.value}" needs a value on the right.`);
978
- return { kind: "cond", left, op, value } satisfies Predicate;
1015
+ return { kind: "cond", left, op, value: this.literal(message) } satisfies Predicate;
979
1016
  }
980
1017
 
981
1018
  /** Whether the next tokens are a value expression rather than a literal: a name, `(`, CASE, or a number followed by an operator. */
@@ -1174,10 +1211,10 @@ class Parser {
1174
1211
  return count.value;
1175
1212
  }
1176
1213
 
1177
- /** `a.b` as one reference; a bare keyword in reference position gets the caller's message. */
1214
+ /** `a.b` as one reference; a bare keyword in reference position gets the caller's message (`keywordError`). */
1178
1215
  private reference(message: string) {
1179
1216
  const token = this.peek();
1180
- if (!isName(token)) throw new DatasetSqlError(message);
1217
+ if (!isName(token)) throw keywordError(token, message);
1181
1218
  this.take();
1182
1219
  let ref = token.value;
1183
1220
  while (this.optionalPunct(".")) {