@m6d/cortex-cli 0.14.0 → 0.16.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.
@@ -88,18 +88,22 @@ export function registerManagement(program: Program, argv: string[]) {
88
88
  }
89
89
 
90
90
  /**
91
- * The key is never a flag (argv is visible to other processes and lands in
92
- * shell history): it is prompted for masked, or read from `CORTEX_KEY`
93
- * when there is no terminal to ask.
91
+ * The URL is `--url`, or asked for on a terminal. The key is never a flag
92
+ * (argv is visible to other processes and lands in shell history): it is
93
+ * prompted for masked, or read from `CORTEX_KEY` when there is no terminal
94
+ * to ask.
94
95
  */
95
96
  async function login(given: string | undefined, name: string) {
96
- const url = requireTls(
97
- (given ?? (await prompts.askValue("Cortex URL", "https://cortex.example.com"))).replace(
98
- /\/+$/,
99
- "",
100
- ),
101
- );
102
- const key = process.stdin.isTTY
97
+ const typed =
98
+ given ??
99
+ (prompts.canAsk()
100
+ ? await prompts.askValue("Cortex URL", "https://cortex.example.com")
101
+ : report.fail({
102
+ what: "No terminal to ask for the Cortex URL",
103
+ blocks: ["Pass it with --url."],
104
+ }));
105
+ const url = requireTls(typed.replace(/\/+$/, ""));
106
+ const key = prompts.canAsk()
103
107
  ? await prompts.askSecret("Management key")
104
108
  : (process.env["CORTEX_KEY"] ??
105
109
  report.fail({
@@ -248,7 +252,8 @@ function groupByRouter(document: OpenApiDocument) {
248
252
  /**
249
253
  * One procedure as one command: a positional for `id`, a typed flag per
250
254
  * field, `--json` for a whole body, `--all` on paginated reads, `--publish`
251
- * on writes to a publishable router.
255
+ * on writes to a publishable router, `--yes` wherever a destructive
256
+ * procedure (`x-destructive`) would run.
252
257
  */
253
258
  function registerOperation(scope: {
254
259
  group: Command;
@@ -273,10 +278,13 @@ function registerOperation(scope: {
273
278
  const publisher = publishWith === undefined ? undefined : byProcedure.get(publishWith);
274
279
  const canPublish = publisher !== undefined;
275
280
  const liveFields = operation["x-live-fields"] ?? [];
281
+ const destructive = operation["x-destructive"] === true;
282
+ const publishDestructive = publisher?.operation["x-destructive"] === true;
276
283
 
277
284
  const command = declareCommand(group, name, operation.summary ?? "", flags, {
278
285
  paginated,
279
286
  canPublish,
287
+ confirms: destructive || publishDestructive,
280
288
  });
281
289
 
282
290
  command.action(async function (...args) {
@@ -285,11 +293,12 @@ function registerOperation(scope: {
285
293
  json?: string;
286
294
  all?: boolean;
287
295
  publish?: boolean;
296
+ yes?: boolean;
288
297
  };
289
298
  // Commander passes the options object first when the command declares
290
299
  // no argument, so only a string is the `[id]` positional.
291
300
  const positional = typeof args[0] === "string" ? args[0] : undefined;
292
- const { output: format, json, all, publish, ...given } = options;
301
+ const { output: format, json, all, publish, yes, ...given } = options;
293
302
  const body = await collectInput(flags, given, json, positional, name);
294
303
  // `-o json` promises stdout holds only the result; prose moves to stderr.
295
304
  const note = format === "json" ? report.aside : report.note;
@@ -297,6 +306,20 @@ function registerOperation(scope: {
297
306
  report.header(`cortex ${router} ${name}`);
298
307
  report.input("Cortex", profile.url);
299
308
  }
309
+ // One question before anything is sent, covering the publish too, so
310
+ // a no leaves no draft behind.
311
+ const confirming = [
312
+ ...(destructive ? [`${router} ${name}`] : []),
313
+ ...(publish === true && publishDestructive
314
+ ? [publisher.operation.operationId.replace(".", " ")]
315
+ : []),
316
+ ];
317
+ if (confirming.length > 0) {
318
+ await confirmDestructive(
319
+ `Running ${confirming.join(" and ")} on ${profile.url} replaces or removes state.`,
320
+ yes,
321
+ );
322
+ }
300
323
  const result = await callOrFail(profile, () =>
301
324
  all
302
325
  ? walkPages(profile, method, path, body)
@@ -320,7 +343,7 @@ function declareCommand(
320
343
  name: string,
321
344
  summary: string,
322
345
  flags: Flag[],
323
- shape: { paginated: boolean; canPublish: boolean },
346
+ shape: { paginated: boolean; canPublish: boolean; confirms: boolean },
324
347
  ) {
325
348
  const command = group
326
349
  .command(name)
@@ -353,9 +376,34 @@ function declareCommand(
353
376
  }
354
377
  if (shape.paginated) command.option("--all", "Walk every page");
355
378
  if (shape.canPublish) command.option("--publish", "Publish the entity right after saving");
379
+ if (shape.confirms)
380
+ command.option("--yes", "Skip the confirmation; required without a terminal");
356
381
  return command;
357
382
  }
358
383
 
384
+ /** What a publish went ahead despite, such as a prompt naming a tool without an @-mention. */
385
+ function warningsOf(published: unknown) {
386
+ return isRecord(published) && Array.isArray(published.warnings)
387
+ ? published.warnings.filter((warning): warning is string => typeof warning === "string")
388
+ : [];
389
+ }
390
+
391
+ /**
392
+ * The go-ahead for a call that replaces or removes state, which `effect`
393
+ * names: `--yes`, or a yes on a terminal. With no terminal to ask, the run
394
+ * fails before anything is sent, so a script never hangs on the question.
395
+ * The hint does not echo argv, which may hold values that must not reach a
396
+ * CI log.
397
+ */
398
+ export async function confirmDestructive(effect: string, yes: boolean | undefined) {
399
+ if (yes === true) return;
400
+ if (prompts.canAsk()) return prompts.askToProceed(`${effect} Continue?`);
401
+ report.fail({
402
+ what: "No terminal to confirm this call",
403
+ blocks: [effect, "Rerun the command with --yes to run it without asking."],
404
+ });
405
+ }
406
+
359
407
  /**
360
408
  * A write to a publishable router is a draft: publish it now, or say how to.
361
409
  * Fields Cortex reads live are already in effect and are named as such,
@@ -378,10 +426,11 @@ async function publishAfterWrite(
378
426
  );
379
427
  }
380
428
  if (publish) {
381
- await callOrFail(profile, () =>
429
+ const published = await callOrFail(profile, () =>
382
430
  callManagement(profile, publisher.method, publisher.path, { id }),
383
431
  );
384
432
  options.note(`Published ${id}.`);
433
+ for (const warning of warningsOf(published)) options.note(`Warning: ${warning}`);
385
434
  } else {
386
435
  options.note(
387
436
  `${live ? "The rest is" : "Saved as"} a draft. Add --publish, or run: ${command}`,
@@ -400,14 +449,18 @@ export async function callOrFail<T>(profile: ResolvedProfile, call: () => Promis
400
449
 
401
450
  /**
402
451
  * A management refusal, or a Cortex that cannot be reached, is the
403
- * run's outcome, in the error grammar, with whatever the refusal names;
404
- * anything else is a bug, and rethrown.
452
+ * run's outcome, in the error grammar: the refusal's literal, the sentence
453
+ * Cortex words it in, then whatever it names; anything else is a bug, and
454
+ * rethrown.
405
455
  */
406
456
  export function failCall(profile: { url: string }, error: unknown): never {
407
457
  if (error instanceof ManagementError) {
408
458
  report.fail({
409
459
  what: `${error.status} ${error.code}: ${error.message}`,
410
- blocks: refusalBlocks(error.detail ?? {}),
460
+ blocks: [
461
+ ...(error.refusal === undefined ? [] : [error.refusal]),
462
+ ...refusalBlocks(error.detail ?? {}),
463
+ ],
411
464
  });
412
465
  }
413
466
  if (error instanceof UnreachableError) {
@@ -451,8 +504,8 @@ function issueLine(issue: unknown) {
451
504
 
452
505
  /**
453
506
  * The body: `--json` first, flags layered over it, the positional as `id`.
454
- * A required field left missing is asked for on a TTY and named as the
455
- * flag, or the positional, to add otherwise.
507
+ * A required field left missing is asked for on a terminal and named as
508
+ * the flag, or the positional, to add otherwise.
456
509
  */
457
510
  async function collectInput(
458
511
  flags: Flag[],
@@ -468,7 +521,7 @@ async function collectInput(
468
521
  if (positional !== undefined) body.set("id", positional);
469
522
 
470
523
  const missing = flags.filter((flag) => flag.required && body.get(flag.name) === undefined);
471
- if (missing.length > 0 && !process.stdin.isTTY) {
524
+ if (missing.length > 0 && !prompts.canAsk()) {
472
525
  const named = (flag: Flag) =>
473
526
  flag.name === "id"
474
527
  ? "<id>"
@@ -9,7 +9,7 @@ import pkg from "../../package.json";
9
9
  import { askProvider } from "@/commands/setup";
10
10
  import { AGENT_SLUG, manifestIdOf, scaffoldFiles } from "@/scaffold/files";
11
11
  import { allocatePorts, type Ports } from "@/scaffold/ports";
12
- import { askConfirm, askName, intro, log, outro, runSteps } from "@/ui/prompts";
12
+ import { askConfirm, askName, canAsk, intro, log, outro, runSteps } from "@/ui/prompts";
13
13
  import * as report from "@/ui/report";
14
14
 
15
15
  /** npm's rule for an unscoped package name. The name is the directory too. */
@@ -46,9 +46,8 @@ type Options = { datasets?: true; setup?: boolean; yes?: true; verbose: boolean
46
46
  async function scaffold(name: string | undefined, options: Options) {
47
47
  const { verbose } = options;
48
48
 
49
- // No TTY ⇒ never prompt and behave as `--yes`. On Bun a piped stdin reports
50
- // `undefined` rather than `false`, which is why this compares against `true`.
51
- const interactive = process.stdin.isTTY === true && options.yes !== true;
49
+ // No terminal ⇒ never prompt and behave as `--yes`.
50
+ const interactive = canAsk() && options.yes !== true;
52
51
 
53
52
  if (name === undefined && !interactive) missingName();
54
53
 
@@ -280,14 +279,13 @@ function isInsideWorkTree(target: string) {
280
279
  }
281
280
 
282
281
  function missingName(): never {
283
- // No TTY ⇒ never prompt, so a missing required argument is a hard error.
284
- // With a TTY this branch is only reached under `--yes`, which answers every
282
+ // No terminal ⇒ never prompt, so a missing required argument is a hard error.
283
+ // With one, this branch is only reached under `--yes`, which answers every
285
284
  // question including the one that has no default.
286
285
  report.fail({
287
- what:
288
- process.stdin.isTTY === true
289
- ? "cortex new needs a project name."
290
- : "cortex new needs a project name when stdin is not a TTY.",
286
+ what: canAsk()
287
+ ? "cortex new needs a project name."
288
+ : "cortex new needs a project name when there is no terminal to ask on.",
291
289
  blocks: [
292
290
  "Pass it as an argument, and --yes to accept defaults for the rest:",
293
291
  " cortex new my-app --yes",
@@ -12,15 +12,23 @@ import {
12
12
  normalizeHost,
13
13
  scrubCredentialStrings,
14
14
  } from "@cortex/contracts/egress-policy";
15
+ import { MODEL_PROVIDER_LIMITS, NAME_MAX_LENGTH } from "@cortex/contracts/manifest";
15
16
  import { z } from "zod";
16
17
 
17
18
  import {
18
19
  applyManifest,
19
20
  DEFAULT_MANIFEST,
20
21
  findManifest,
22
+ loadManifest,
21
23
  printTriggerSecrets,
22
24
  } from "@/commands/apply";
23
- import { callOrFail, failCall, requireProfile, walkPages } from "@/commands/management";
25
+ import {
26
+ callOrFail,
27
+ confirmDestructive,
28
+ failCall,
29
+ requireProfile,
30
+ walkPages,
31
+ } from "@/commands/management";
24
32
  import { callManagement, ManagementError, UnreachableError } from "@/management/client";
25
33
  import { DOCKER_HOST, EMBEDDING, withEnvValues } from "@/scaffold/files";
26
34
  import * as prompts from "@/ui/prompts";
@@ -34,7 +42,7 @@ const POLL_MS = 2_000;
34
42
 
35
43
  /** The backend as `cortex new` writes it into .env, which Bun loads from the directory it runs in. */
36
44
  const projectSchema = z.object({
37
- CORTEX_SERVER_NAME: z.string().min(1),
45
+ CORTEX_SERVER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
38
46
  CORTEX_SERVER_URL: z.url(),
39
47
  CORTEX_SERVER_KEY: z.string().min(1),
40
48
  });
@@ -48,14 +56,16 @@ const ENV_SAFE = /^[^\s$#'"`\\]+$/;
48
56
 
49
57
  /** The model provider in .env: all four values, or none. */
50
58
  const providerFields = z.object({
51
- // Its key's secret is `<name>-api-key-<digest>`, within Cortex's 120 characters.
52
- MODEL_PROVIDER_NAME: z.string().min(1).max(103),
53
- MODEL_PROVIDER_URL: z.string().superRefine(function (value, context) {
54
- const problem = checkBaseUrl(value);
55
- if (problem) context.addIssue({ code: "custom", message: problem });
56
- }),
59
+ MODEL_PROVIDER_NAME: z.string().min(1).max(NAME_MAX_LENGTH),
60
+ MODEL_PROVIDER_URL: z
61
+ .string()
62
+ .max(MODEL_PROVIDER_LIMITS.urlLength)
63
+ .superRefine(function (value, context) {
64
+ const problem = checkBaseUrl(value);
65
+ if (problem) context.addIssue({ code: "custom", message: problem });
66
+ }),
57
67
  MODEL_PROVIDER_API_KEY: z.string().min(1),
58
- MODEL_PROVIDER_MODEL: z.string().min(1).max(200),
68
+ MODEL_PROVIDER_MODEL: z.string().min(1).max(MODEL_PROVIDER_LIMITS.modelLength),
59
69
  });
60
70
  const providerEnvSchema = providerFields.transform((env) => ({
61
71
  name: env.MODEL_PROVIDER_NAME,
@@ -94,8 +104,12 @@ const embeddingsSchema = z.object({
94
104
  * creates what is missing and keeps what exists, so it runs again on every
95
105
  * `bun run dev`. A provider or server under .env's name must call .env's URL,
96
106
  * or setup leaves it alone and stops, so no key reaches a URL .env did not name. With no model provider in .env it asks for
97
- * one on a terminal, and under `--yes` registers the server and applies
98
- * nothing, since Cortex publishes no agent without a model.
107
+ * one on a terminal, and under `--yes` registers the server without a model
108
+ * and applies all the same, since an agent may name its own; when Cortex
109
+ * refuses an agent for want of one, setup says so and applies nothing. The
110
+ * apply deletes what the manifest no longer lists, so setup checks the
111
+ * manifest, then waits for a yes on a terminal or `--yes`, before it asks or
112
+ * writes anything else, as `cortex apply` does.
99
113
  */
100
114
  export function registerSetup(program: Command<[], { verbose?: true; profile?: string }>) {
101
115
  const command = program
@@ -103,19 +117,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
103
117
  .helpGroup("Project:")
104
118
  .description("Register this project's backend and model in Cortex, then apply its manifest")
105
119
  .option("--wait", "Wait for Cortex to come up first")
106
- .option("--yes", "Never ask; leave out what .env does not hold");
120
+ .option(
121
+ "--yes",
122
+ "Never ask: leave out what .env does not hold, and apply without confirming",
123
+ );
107
124
 
108
125
  command.action(async function (options) {
109
126
  const { verbose = false, profile: profileName } = program.opts();
110
127
  const project = readProject();
111
128
  const profile = requireProfile(profileName);
112
- const file = findManifest(DEFAULT_MANIFEST);
113
- const interactive = process.stdin.isTTY === true && options.yes !== true;
129
+ const manifest = await loadManifest(findManifest(DEFAULT_MANIFEST), verbose);
130
+ const interactive = prompts.canAsk() && options.yes !== true;
114
131
  if (profileName === undefined) requireEnvCortex(profile.url);
115
132
 
116
133
  report.header("cortex setup");
117
134
  await waitForCortex(profile, options.wait === true);
118
135
  report.input("Cortex", profile.url);
136
+ await confirmDestructive(
137
+ `Applying deletes what the manifest created on ${profile.url} and no longer lists.`,
138
+ options.yes,
139
+ );
119
140
 
120
141
  const provider = readProvider() ?? (interactive ? await askAndSave() : undefined);
121
142
  if (provider) await requireReachable(provider.baseUrl);
@@ -124,21 +145,26 @@ export function registerSetup(program: Command<[], { verbose?: true; profile?: s
124
145
  const server = await ensureServer(profile, project, provider);
125
146
  await syncServer(profile, server.id, project.CORTEX_SERVER_URL);
126
147
 
127
- if (server.modelProvider === null) {
148
+ const applied = await applyManifest({
149
+ profile,
150
+ manifest,
151
+ dryRun: false,
152
+ interactive,
153
+ command: "bun cortex apply --yes",
154
+ }).catch(function (error: unknown) {
155
+ if (error instanceof ManagementError && error.message === "chat_model_required") {
156
+ return undefined;
157
+ }
158
+ return failCall(profile, error);
159
+ });
160
+ if (!applied) {
128
161
  report.footer("no model yet, so nothing applied");
129
162
  report.note("Run `bun cortex setup` in a terminal to add a model provider.");
130
163
  return;
131
164
  }
132
-
133
- const { changes, triggerSecrets } = await applyManifest({
134
- profile,
135
- file,
136
- dryRun: false,
137
- verbose,
138
- });
139
165
  report.footer(`live · chat at ${profile.url}/playground`);
140
- printTriggerSecrets(triggerSecrets);
141
- if (Object.values(changes.create).flat().length > 0) printSignIn(profile.url);
166
+ printTriggerSecrets(applied.triggerSecrets);
167
+ if (Object.values(applied.changes.create).flat().length > 0) printSignIn(profile.url);
142
168
  });
143
169
  }
144
170
 
@@ -146,8 +172,9 @@ function readProject() {
146
172
  const parsed = projectSchema.safeParse(process.env);
147
173
  if (parsed.success) return parsed.data;
148
174
  return report.fail({
149
- what: "No project here for cortex setup",
175
+ what: "No project here cortex setup can register",
150
176
  blocks: [
177
+ z.prettifyError(parsed.error),
151
178
  "It reads CORTEX_SERVER_NAME, CORTEX_SERVER_URL and CORTEX_SERVER_KEY from\n.env, which `cortex new` writes. Run it from that project's root.",
152
179
  ],
153
180
  });
@@ -242,13 +269,15 @@ async function findByName<TItem extends typeof namedSchema>(
242
269
  }
243
270
 
244
271
  /**
245
- * A secret's name for one value: its base and a short digest of the value.
246
- * A name then holds one value only, so a secret that exists is the value
247
- * .env holds, and a changed key gets a secret of its own while every row
248
- * presenting the old one keeps it.
272
+ * A secret's name for one value: its owner's name, what it is to the owner,
273
+ * and a short digest of the value, the owner's name cut so the whole is a
274
+ * name Cortex holds. A name then holds one value only, so a secret that
275
+ * exists is the value .env holds, and a changed key gets a secret of its own
276
+ * while every row presenting the old one keeps it.
249
277
  */
250
- function secretFor(base: string, value: string) {
251
- return `${base}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
278
+ function secretFor(owner: string, role: "api-key" | "server-key", value: string) {
279
+ const suffix = `-${role}-${createHash("sha256").update(value).digest("hex").slice(0, 8)}`;
280
+ return `${owner.slice(0, NAME_MAX_LENGTH - suffix.length)}${suffix}`;
252
281
  }
253
282
 
254
283
  async function ensureSecret(profile: Profile, name: string, value: string) {
@@ -271,7 +300,9 @@ function failCallsElsewhere(what: string, calls: string | null, wanted: string,
271
300
 
272
301
  /**
273
302
  * The provider, on its key's secret. One that exists takes a changed key and
274
- * the .env model as well; one that calls another URL ends the run.
303
+ * the .env model as well; one that calls another URL, or lists as many
304
+ * models as Cortex allows without the .env one, ends the run before any
305
+ * write.
275
306
  */
276
307
  async function ensureProvider(profile: Profile, provider: Provider) {
277
308
  const found = await findByName(profile, "model-providers", provider.name, providerRowSchema);
@@ -283,7 +314,17 @@ async function ensureProvider(profile: Profile, provider: Provider) {
283
314
  "MODEL_PROVIDER_NAME",
284
315
  );
285
316
  }
286
- const secretName = secretFor(`${provider.name}-api-key`, provider.apiKey);
317
+ // Added, never replaced: a model a server still runs on stays offered.
318
+ const models = [...new Set([...(found?.models ?? []), provider.model])];
319
+ if (models.length > MODEL_PROVIDER_LIMITS.models) {
320
+ report.fail({
321
+ what: `Provider ${provider.name} in Cortex lists ${MODEL_PROVIDER_LIMITS.models} models, not ${provider.model}`,
322
+ blocks: [
323
+ "Cortex lists no more on one provider. Remove one in the console, or set\nMODEL_PROVIDER_MODEL in .env to a model it lists.",
324
+ ],
325
+ });
326
+ }
327
+ const secretName = secretFor(provider.name, "api-key", provider.apiKey);
287
328
  await ensureSecret(profile, secretName, provider.apiKey);
288
329
 
289
330
  if (!found) {
@@ -291,7 +332,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
291
332
  name: provider.name,
292
333
  baseUrl: provider.baseUrl,
293
334
  secretName,
294
- models: [provider.model],
335
+ models,
295
336
  efforts: [],
296
337
  });
297
338
  report.input("Provider", `${provider.name} · created`);
@@ -305,8 +346,7 @@ async function ensureProvider(profile: Profile, provider: Provider) {
305
346
  id: found.id,
306
347
  baseUrl: found.baseUrl,
307
348
  secretName,
308
- // Added, never replaced: a model a server still runs on stays offered.
309
- models: [...new Set([...found.models, provider.model])],
349
+ models,
310
350
  efforts: found.efforts,
311
351
  });
312
352
  report.input("Provider", `${provider.name} · updated`);
@@ -332,7 +372,7 @@ async function ensureServer(
332
372
  if (server && server.url !== url) {
333
373
  failCallsElsewhere(`Server ${name}`, server.url, url, "CORTEX_SERVER_NAME");
334
374
  }
335
- const keySecretName = secretFor(`${name}-server-key`, project.CORTEX_SERVER_KEY);
375
+ const keySecretName = secretFor(name, "server-key", project.CORTEX_SERVER_KEY);
336
376
  await ensureSecret(profile, keySecretName, project.CORTEX_SERVER_KEY);
337
377
 
338
378
  const model = {
@@ -15,7 +15,14 @@ import type {
15
15
  DatasetQueryInput,
16
16
  DatasetRelation,
17
17
  } from "./datasets";
18
- import { DATASET_BUCKETS, DATASET_DATE_PARTS, describeOperand, groupKeyName } from "./datasets";
18
+ import {
19
+ DATASET_BUCKETS,
20
+ DATASET_DATE_PARTS,
21
+ DATASET_REQUEST_MAX_BYTES,
22
+ describeOperand,
23
+ groupKeyName,
24
+ unknownColumnError,
25
+ } from "./datasets";
19
26
 
20
27
  /*
21
28
  * The SQL a model writes against a dataset, and how it becomes the wire
@@ -272,6 +279,15 @@ const WINDOW_PLACE =
272
279
  "RANK() goes in QUALIFY (QUALIFY RANK() OVER (PARTITION BY a ORDER BY b DESC) <= n) or in a derived table filtered on its alias (FROM (SELECT ..., RANK() OVER (...) AS rn FROM ...) WHERE rn <= n).";
273
280
  const INTERVAL_PLACE =
274
281
  "INTERVAL only shifts CURRENT_DATE (CURRENT_DATE - INTERVAL '7 days'); compare the column against a date instead.";
282
+ const TOO_LARGE =
283
+ "The statement grows too large to send: a simple CASE repeats its subject in every WHEN, so a CASE nested in that subject multiplies. Compare a column instead, or ask in smaller queries.";
284
+
285
+ /**
286
+ * The most tokens a statement may read, a simple CASE's subject counted again
287
+ * for every WHEN that repeats it. A token writes about a byte or more of the
288
+ * wire query, so a statement past this could never be sent.
289
+ */
290
+ const EXPANDED_TOKENS_MAX = DATASET_REQUEST_MAX_BYTES;
275
291
 
276
292
  const REFUSED_CLAUSES = {
277
293
  UNION: "UNION is not supported: ask each dataset in its own query.",
@@ -396,6 +412,8 @@ function datePartTerm(unit: DatasetDatePart, of: Term) {
396
412
 
397
413
  class Parser {
398
414
  private position = 0;
415
+ /** Tokens read so far, a simple CASE's subject counted once per WHEN: how large the statement grows once written out. */
416
+ private expanded = 0;
399
417
 
400
418
  public constructor(
401
419
  private readonly tokens: Token[],
@@ -763,11 +781,15 @@ class Parser {
763
781
  */
764
782
  private caseTerm() {
765
783
  this.take();
784
+ const start = this.expanded;
766
785
  const subject = this.peekWord("WHEN")
767
786
  ? undefined
768
787
  : this.term("CASE is followed by WHEN, or by a value to compare.");
788
+ const subjectSize = this.expanded - start;
769
789
  const branches: { when: Predicate[]; then: Term }[] = [];
770
790
  while (this.optionalWord("WHEN")) {
791
+ // The wire has no simple CASE: every WHEN after the first writes the subject out again.
792
+ if (branches.length > 0) this.expand(subjectSize);
771
793
  const when = subject ? [this.simpleCaseMatch(subject)] : this.predicates();
772
794
  this.expectWord("THEN", "CASE WHEN condition needs THEN value.");
773
795
  if (this.peekWord("NULL")) {
@@ -1224,8 +1246,14 @@ class Parser {
1224
1246
  private take() {
1225
1247
  const token = this.peek();
1226
1248
  this.position += 1;
1249
+ this.expand(1);
1227
1250
  return token;
1228
1251
  }
1252
+ /** Counts `tokens` toward the statement's written-out size, refused the moment it passes what a request carries. */
1253
+ private expand(tokens: number) {
1254
+ this.expanded += tokens;
1255
+ if (this.expanded > EXPANDED_TOKENS_MAX) throw new DatasetSqlError(TOO_LARGE);
1256
+ }
1229
1257
  private peekWord(upper: string) {
1230
1258
  const token = this.peek();
1231
1259
  return token.kind === "word" && token.upper === upper;
@@ -1328,11 +1356,16 @@ function unqualified(ref: string, table: string | undefined) {
1328
1356
  return table !== undefined && ref.startsWith(`${table}.`) ? ref.slice(table.length + 1) : ref;
1329
1357
  }
1330
1358
 
1331
- /** The outer list of a derived table: `*` keeps every inner value, names keep those; the RANK() itself is never answered. */
1359
+ /**
1360
+ * The outer list of a derived table: `*` keeps every inner value, names keep
1361
+ * those, each once since every listing writes the value out again; the
1362
+ * RANK() itself is never answered.
1363
+ */
1332
1364
  function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | undefined) {
1333
1365
  const values = inner.filter((item) => item.kind !== "rankWindow");
1334
1366
  if (outer.some((item) => item.kind === "star")) return values;
1335
1367
  const windowNames = inner.flatMap((item) => (item.kind === "rankWindow" ? [item.alias] : []));
1368
+ const listed = new Set<SelectItem>();
1336
1369
  const kept = outer.flatMap((item) => {
1337
1370
  const name =
1338
1371
  item.kind === "column" && item.alias === undefined
@@ -1346,6 +1379,12 @@ function narrowed(inner: SelectItem[], outer: SelectItem[], table: string | unde
1346
1379
  "The outer SELECT of a derived table lists names the inner SELECT answers, or *.",
1347
1380
  );
1348
1381
  }
1382
+ if (listed.has(found)) {
1383
+ throw new DatasetSqlError(
1384
+ `The outer SELECT lists "${name}" twice; list each value of the derived table once.`,
1385
+ );
1386
+ }
1387
+ listed.add(found);
1349
1388
  return [found];
1350
1389
  });
1351
1390
  const unnamed = values.flatMap((value) =>
@@ -1658,6 +1697,11 @@ class Sources {
1658
1697
  `"${qualifier}" is a link table; read its columns from the datasets it links.`,
1659
1698
  );
1660
1699
  }
1700
+ // Checked here, where the query's own dataset name is known: the wire
1701
+ // reference names the relation path instead.
1702
+ if (!source.dataset.columns.some((c) => c.name === column)) {
1703
+ throw new DatasetSqlError(unknownColumnError(source.dataset, column));
1704
+ }
1661
1705
  return source.path ? `${source.path}.${column}` : column;
1662
1706
  }
1663
1707
 
@@ -1747,10 +1791,8 @@ class Sources {
1747
1791
  `"${column}" is a column of ${owners.map((o) => o.dataset.name).join(" and ")}; qualify it as dataset.column.`,
1748
1792
  );
1749
1793
  }
1750
- const joined = this.paths.length ? " or of the joined datasets" : "";
1751
- throw new DatasetSqlError(
1752
- `"${column}" is not a column of ${this.root.dataset.name}${joined}.`,
1753
- );
1794
+ const joined = this.paths.length ? " Nor does any joined dataset." : "";
1795
+ throw new DatasetSqlError(unknownColumnError(this.root.dataset, column) + joined);
1754
1796
  }
1755
1797
 
1756
1798
  /** Every readable dataset once, whatever it is named as. */
@@ -2073,7 +2115,8 @@ function resolveGroupKeys(
2073
2115
  key = twin ?? { expr, as: lastSegment(column ?? "") };
2074
2116
  }
2075
2117
  const taken = keys.get(groupKeyName(key));
2076
- if (taken && !same(operandOf(taken), operandOf(key))) {
2118
+ // A name grouped again is the very same key; only a different one is written out to compare.
2119
+ if (taken && taken !== key && !same(operandOf(taken), operandOf(key))) {
2077
2120
  throw new DatasetSqlError(
2078
2121
  `Two group keys would answer as "${groupKeyName(key)}"; name each value: ${describeTerm(group)} AS name.`,
2079
2122
  );
@@ -2466,10 +2509,18 @@ function calendarDate(now: Date, timeZone: string | undefined) {
2466
2509
  }
2467
2510
  }
2468
2511
 
2512
+ /** The longest statement a query may be: room for any query a model writes, and a bound on the work one costs. */
2513
+ export const DATASET_SQL_MAX_LENGTH = 8_000;
2514
+
2469
2515
  /** Parses one SELECT against the catalog into the wire query; throws {@link DatasetSqlError} with what to write instead. */
2470
2516
  export function parseDatasetSql(sql: string, catalog: DatasetCatalog, options: ParseOptions) {
2471
2517
  const trimmed = sql.trim();
2472
2518
  if (!trimmed) throw new DatasetSqlError("The query is empty.");
2519
+ if (trimmed.length > DATASET_SQL_MAX_LENGTH) {
2520
+ throw new DatasetSqlError(
2521
+ `The query is longer than ${DATASET_SQL_MAX_LENGTH} characters; ask for less at once.`,
2522
+ );
2523
+ }
2473
2524
  const today = calendarDate(options.now, options.timeZone);
2474
2525
  return compile(catalog, new Parser(tokenize(trimmed), today).statement());
2475
2526
  }