railcode 0.1.13 → 0.1.14

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/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { createInterface } from "node:readline/promises";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
14
14
  import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
15
+ import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
15
16
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
16
17
  // ---------------------------------------------------------------------------
17
18
  // Constants
@@ -35,6 +36,7 @@ Usage:
35
36
  railcode init <app> [--template static|react]
36
37
  railcode design-system Print your org's design-system guidance (markdown)
37
38
  railcode db <list|query> ... List data connectors / run read-only SQL
39
+ railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
38
40
  railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
39
41
  railcode --version
40
42
  railcode --help
@@ -76,6 +78,10 @@ async function main() {
76
78
  case "db":
77
79
  await commandDb(args);
78
80
  return;
81
+ case "query":
82
+ case "queries":
83
+ await commandQuery(args);
84
+ return;
79
85
  case "connector":
80
86
  case "connectors":
81
87
  await commandConnector(args);
@@ -101,13 +107,13 @@ function parseArgs(argv) {
101
107
  const withoutPrefix = item.slice(2);
102
108
  const eq = withoutPrefix.indexOf("=");
103
109
  if (eq !== -1) {
104
- options[toCamel(withoutPrefix.slice(0, eq))] = withoutPrefix.slice(eq + 1);
110
+ setOption(options, toCamel(withoutPrefix.slice(0, eq)), withoutPrefix.slice(eq + 1));
105
111
  continue;
106
112
  }
107
113
  const key = toCamel(withoutPrefix);
108
114
  const next = tail[i + 1];
109
115
  if (next && !next.startsWith("--")) {
110
- options[key] = next;
116
+ setOption(options, key, next);
111
117
  i += 1;
112
118
  }
113
119
  else {
@@ -116,6 +122,20 @@ function parseArgs(argv) {
116
122
  }
117
123
  return { command, rest, options };
118
124
  }
125
+ // A string option given twice accumulates into an array (optionString then
126
+ // rejects it with "can only be given once"); a repeated flag just stays true.
127
+ function setOption(options, key, value) {
128
+ const existing = options[key];
129
+ if (typeof existing === "string") {
130
+ options[key] = [existing, value];
131
+ }
132
+ else if (Array.isArray(existing)) {
133
+ existing.push(value);
134
+ }
135
+ else {
136
+ options[key] = value;
137
+ }
138
+ }
119
139
  function toCamel(input) {
120
140
  return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
121
141
  }
@@ -174,9 +194,15 @@ async function authorizeCliWithBrowser(apiUrl) {
174
194
  redirect_uri: callback.redirectUri,
175
195
  state,
176
196
  })}`;
177
- console.log("Open this link to authorize the Railcode CLI:");
178
- console.log(authorizeUrl);
179
197
  console.log("");
198
+ printUrlBox("Railcode login", authorizeUrl);
199
+ console.log("");
200
+ if (openBrowser(authorizeUrl)) {
201
+ console.log("Opening your browser. If it does not open, paste the URL above.");
202
+ }
203
+ else {
204
+ console.log("Paste the URL above into your browser to continue.");
205
+ }
180
206
  console.log("Waiting for browser authorization...");
181
207
  const code = await callback.code;
182
208
  return await exchangeCliAuthCode(apiUrl, code);
@@ -267,6 +293,25 @@ async function startCliAuthCallback(expectedState) {
267
293
  },
268
294
  };
269
295
  }
296
+ function openBrowser(url) {
297
+ const opener = process.platform === "darwin"
298
+ ? { command: "open", args: [url] }
299
+ : process.platform === "win32"
300
+ ? { command: "cmd", args: ["/c", "start", "", url] }
301
+ : { command: "xdg-open", args: [url] };
302
+ try {
303
+ const child = spawn(opener.command, opener.args, {
304
+ detached: true,
305
+ stdio: "ignore",
306
+ });
307
+ child.on("error", () => { });
308
+ child.unref();
309
+ return true;
310
+ }
311
+ catch {
312
+ return false;
313
+ }
314
+ }
270
315
  function sendBrowserCallback(response, status, message) {
271
316
  response.writeHead(status, { "Content-Type": "text/html" });
272
317
  response.end(`<!doctype html><title>Railcode CLI</title><p>${escapeHtml(message)}</p>`);
@@ -856,9 +901,9 @@ async function commandDbQuery(args) {
856
901
  if (args.rest.length > 2) {
857
902
  throw new CliError('railcode db query takes a single SQL string — quote it: railcode db query "select 1".', 2);
858
903
  }
859
- const source = pickSqlSource(args.rest[1], args.options.file);
904
+ const source = pickSqlSource(args.rest[1], singleOption(args, "file"));
860
905
  const params = parseSqlParams(optionString(args, "params"));
861
- const engineOption = parseEngine(args.options.engine);
906
+ const engineOption = parseEngine(singleOption(args, "engine"));
862
907
  const connection = optionString(args, "connection") ?? "default";
863
908
  const query = source.kind === "file" ? readSqlFile(source.path) : source.sql;
864
909
  const config = requireLogin(args);
@@ -905,8 +950,20 @@ function optionString(args, key) {
905
950
  const value = args.options[key];
906
951
  if (value === true)
907
952
  throw new CliError(`--${camelToKebab(key)} requires a value.`, 2);
953
+ if (Array.isArray(value)) {
954
+ throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
955
+ }
908
956
  return typeof value === "string" ? value : undefined;
909
957
  }
958
+ // A single-valued option that may legitimately be a bare flag (the db/connector
959
+ // parsers handle `true` themselves) — only a REPEATED option is rejected here.
960
+ function singleOption(args, key) {
961
+ const value = args.options[key];
962
+ if (Array.isArray(value)) {
963
+ throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
964
+ }
965
+ return value;
966
+ }
910
967
  function assertKnownDbOptions(args, allowed) {
911
968
  for (const key of Object.keys(args.options)) {
912
969
  if (!allowed.includes(key)) {
@@ -918,6 +975,254 @@ function camelToKebab(value) {
918
975
  return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
919
976
  }
920
977
  // ---------------------------------------------------------------------------
978
+ // query — saved queries (`railcode query list|run|create|delete`)
979
+ //
980
+ // list/run hit the org-scoped `/data/queries` plane with the personal token
981
+ // (subject to `saved_query` grants; `_ctx_*` binds resolve to YOU). create/delete
982
+ // hit the admin management API (`/saved-queries`, connection:manage required).
983
+ // ---------------------------------------------------------------------------
984
+ const QUERY_HELP = `railcode query — saved queries
985
+
986
+ Usage:
987
+ railcode query list List this org's saved queries
988
+ railcode query run <name> [--params <json>] Invoke a saved query by name
989
+ railcode query create --name <n> --connection <c> (--sql <sql> | --sql-file <f>)
990
+ [--params <json>] [--description <d>]
991
+ Author a saved query (admin)
992
+ railcode query update <name> [--sql <sql> | --sql-file <f>] [--params <json>]
993
+ [--clear-params] [--connection <c>] [--description <d>]
994
+ Edit a saved query in place (admin;
995
+ omitted fields keep their value)
996
+ railcode query delete <name> Delete a saved query (admin)
997
+
998
+ Aliases: query = queries; list = ls; run = invoke.
999
+
1000
+ Run options:
1001
+ --params '<json-object>' Caller params, exactly as the API takes
1002
+ them: '{"region":"emea","limit":5}'.
1003
+ Params declared with a default may be
1004
+ omitted (the server binds the default).
1005
+ --json Print the raw { columns, rows, ... } envelope
1006
+
1007
+ Create / update options:
1008
+ --name <name> Invoke handle ([a-z0-9_], unique per org)
1009
+ --connection <name> Data connection the query runs against
1010
+ --sql <sql> The SQL template inline (:param style);
1011
+ quote it, or use --sql="..." if it starts
1012
+ with a -- comment
1013
+ --sql-file <path> File holding the SQL template
1014
+ --params '<json-array>' Declared params in the API shape:
1015
+ '[{"name":"region","type":"string","default":"emea"},
1016
+ {"name":"limit","type":"int"}]'
1017
+ Types: string, int, float, bool. A "default"
1018
+ makes the param optional at invoke time.
1019
+ On update the array REPLACES the params
1020
+ --clear-params (update only) declare no params
1021
+ --description <text> Shown to members next to the name
1022
+
1023
+ Editing the SQL or params bumps the query's version; grants naming the query are
1024
+ untouched (unlike delete + recreate, which removes them).
1025
+
1026
+ Templates may reference :_ctx_user_id, :_ctx_user_email and :_ctx_org — the server
1027
+ injects those from whoever invokes (they can never be passed as params), so a
1028
+ query like "where rep_email = :_ctx_user_email" is row-scoped per caller.
1029
+ `;
1030
+ async function commandQuery(args) {
1031
+ try {
1032
+ const sub = args.rest[0] ?? "";
1033
+ switch (sub) {
1034
+ case "list":
1035
+ case "ls":
1036
+ await commandQueryList(args);
1037
+ return;
1038
+ case "run":
1039
+ case "invoke":
1040
+ await commandQueryRun(args);
1041
+ return;
1042
+ case "create":
1043
+ await commandQueryCreate(args);
1044
+ return;
1045
+ case "update":
1046
+ await commandQueryUpdate(args);
1047
+ return;
1048
+ case "delete":
1049
+ await commandQueryDelete(args);
1050
+ return;
1051
+ case "":
1052
+ case "help":
1053
+ console.log(QUERY_HELP);
1054
+ return;
1055
+ default:
1056
+ throw new CliError(`Unknown query command: ${sub}\n\n${QUERY_HELP}`, 2);
1057
+ }
1058
+ }
1059
+ catch (error) {
1060
+ if (error instanceof QueryCmdError)
1061
+ throw new CliError(error.message, 2);
1062
+ throw error;
1063
+ }
1064
+ }
1065
+ async function commandQueryList(args) {
1066
+ assertKnownQueryOptions(args, ["json", "apiUrl"]);
1067
+ if (args.rest.length > 1) {
1068
+ throw new CliError(`railcode query list takes no arguments (got "${args.rest[1]}").`, 2);
1069
+ }
1070
+ const config = requireLogin(args);
1071
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries`);
1072
+ if (resp.status === 401)
1073
+ await reLoginOrThrow();
1074
+ const queries = (await readJsonOrThrow(resp, "List saved queries"));
1075
+ if (args.options.json) {
1076
+ console.log(JSON.stringify(queries, null, 2));
1077
+ return;
1078
+ }
1079
+ if (queries.length === 0) {
1080
+ console.log("No saved queries in this organization.");
1081
+ return;
1082
+ }
1083
+ console.log(formatTable(["name", "params", "version", "description"], queries.map((query) => [
1084
+ query.name,
1085
+ paramSignature(query.params),
1086
+ `v${query.version}`,
1087
+ query.description || "-",
1088
+ ])));
1089
+ }
1090
+ async function commandQueryRun(args) {
1091
+ assertKnownQueryOptions(args, ["params", "json", "apiUrl"]);
1092
+ const name = args.rest[1];
1093
+ if (!name || args.rest.length > 2) {
1094
+ throw new CliError(`Usage: railcode query run <name> [--params '{"k":"v"}']`, 2);
1095
+ }
1096
+ const params = parseRunParams(optionString(args, "params"));
1097
+ const config = requireLogin(args);
1098
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries/${encodeURIComponent(name)}`, {
1099
+ method: "POST",
1100
+ headers: { "Content-Type": "application/json" },
1101
+ body: JSON.stringify({ params }),
1102
+ });
1103
+ if (resp.status === 401)
1104
+ await reLoginOrThrow();
1105
+ const result = (await readJsonOrThrow(resp, `Run saved query "${name}"`));
1106
+ if (args.options.json) {
1107
+ console.log(JSON.stringify(result, null, 2));
1108
+ return;
1109
+ }
1110
+ console.log(formatTable(result.columns, result.rows));
1111
+ console.log("");
1112
+ console.log(`(${result.rowcount} row${result.rowcount === 1 ? "" : "s"})`);
1113
+ if (result.truncated) {
1114
+ console.log("Note: results were truncated by the server.");
1115
+ }
1116
+ }
1117
+ async function commandQueryCreate(args) {
1118
+ assertKnownQueryOptions(args, [
1119
+ "name",
1120
+ "connection",
1121
+ "sql",
1122
+ "sqlFile",
1123
+ "params",
1124
+ "description",
1125
+ "apiUrl",
1126
+ ]);
1127
+ const name = optionString(args, "name");
1128
+ const connection = optionString(args, "connection");
1129
+ if (!name || !connection) {
1130
+ throw new CliError("railcode query create requires --name and --connection.\n\n" + QUERY_HELP, 2);
1131
+ }
1132
+ // required=true: pickAuthoringSql throws rather than return null here.
1133
+ const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), true);
1134
+ const params = parseParamDecls(optionString(args, "params"));
1135
+ const sql = source.kind === "file" ? readSqlFile(source.path) : source.sql;
1136
+ const config = requireLogin(args);
1137
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries`, {
1138
+ method: "POST",
1139
+ headers: { "Content-Type": "application/json" },
1140
+ body: JSON.stringify({
1141
+ name,
1142
+ connection,
1143
+ sql,
1144
+ params,
1145
+ description: optionString(args, "description") ?? "",
1146
+ }),
1147
+ });
1148
+ if (resp.status === 401)
1149
+ await reLoginOrThrow();
1150
+ const created = (await readJsonOrThrow(resp, `Create saved query "${name}"`));
1151
+ console.log(`Saved query "${created.name}" created (v${created.version}).`);
1152
+ console.log(`Invoke it: railcode query run ${created.name}`);
1153
+ }
1154
+ async function commandQueryUpdate(args) {
1155
+ assertKnownQueryOptions(args, [
1156
+ "sql",
1157
+ "sqlFile",
1158
+ "params",
1159
+ "clearParams",
1160
+ "connection",
1161
+ "description",
1162
+ "apiUrl",
1163
+ ]);
1164
+ const name = args.rest[1];
1165
+ if (!name || args.rest.length > 2) {
1166
+ throw new CliError("Usage: railcode query update <name> [--sql ... | --sql-file ...]", 2);
1167
+ }
1168
+ const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), false);
1169
+ const sql = source ? (source.kind === "file" ? readSqlFile(source.path) : source.sql) : undefined;
1170
+ const paramsJson = optionString(args, "params");
1171
+ const clearParams = args.options.clearParams === true;
1172
+ if (clearParams && paramsJson !== undefined) {
1173
+ throw new CliError("--clear-params cannot be combined with --params.", 2);
1174
+ }
1175
+ const params = clearParams ? [] : paramsJson !== undefined ? parseParamDecls(paramsJson) : undefined;
1176
+ const connection = optionString(args, "connection");
1177
+ const description = optionString(args, "description");
1178
+ // PATCH semantics: only the fields present in the body change; params replace
1179
+ // the whole declared list (the backend re-validates them against the SQL).
1180
+ const body = {};
1181
+ if (connection !== undefined)
1182
+ body.connection = connection;
1183
+ if (sql !== undefined)
1184
+ body.sql = sql;
1185
+ if (params !== undefined)
1186
+ body.params = params;
1187
+ if (description !== undefined)
1188
+ body.description = description;
1189
+ if (Object.keys(body).length === 0) {
1190
+ throw new CliError("Nothing to update. Pass --sql/--sql-file, --params/--clear-params, --connection or --description.", 2);
1191
+ }
1192
+ const config = requireLogin(args);
1193
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, {
1194
+ method: "PATCH",
1195
+ headers: { "Content-Type": "application/json" },
1196
+ body: JSON.stringify(body),
1197
+ });
1198
+ if (resp.status === 401)
1199
+ await reLoginOrThrow();
1200
+ const updated = (await readJsonOrThrow(resp, `Update saved query "${name}"`));
1201
+ console.log(`Saved query "${updated.name}" updated (v${updated.version}).`);
1202
+ }
1203
+ async function commandQueryDelete(args) {
1204
+ assertKnownQueryOptions(args, ["apiUrl"]);
1205
+ const name = args.rest[1];
1206
+ if (!name || args.rest.length > 2) {
1207
+ throw new CliError("Usage: railcode query delete <name>", 2);
1208
+ }
1209
+ const config = requireLogin(args);
1210
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, { method: "DELETE" });
1211
+ if (resp.status === 401)
1212
+ await reLoginOrThrow();
1213
+ if (!resp.ok) {
1214
+ throw new CliError(`Delete saved query "${name}" failed (${resp.status}): ${await errorDetail(resp)}`, 1);
1215
+ }
1216
+ console.log(`Saved query "${name}" deleted (its grants were removed too).`);
1217
+ }
1218
+ function assertKnownQueryOptions(args, allowed) {
1219
+ for (const key of Object.keys(args.options)) {
1220
+ if (!allowed.includes(key)) {
1221
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode query\`.\n\n${QUERY_HELP}`, 2);
1222
+ }
1223
+ }
1224
+ }
1225
+ // ---------------------------------------------------------------------------
921
1226
  // connector — list service connectors + proxy one call
922
1227
  // (`railcode connector list|fetch`)
923
1228
  //
@@ -1007,9 +1312,9 @@ async function commandConnectorFetch(args) {
1007
1312
  if (args.rest.length > 2) {
1008
1313
  throw new CliError('railcode connector fetch takes a single path — quote it: railcode connector fetch "/v1/charges".', 2);
1009
1314
  }
1010
- const connector = parseConnectorOption(args.options.connector);
1011
- const method = parseMethodOption(args.options.method);
1012
- const bodySource = pickConnectorBody(args.options.body, args.options.file);
1315
+ const connector = parseConnectorOption(singleOption(args, "connector"));
1316
+ const method = parseMethodOption(singleOption(args, "method"));
1317
+ const bodySource = pickConnectorBody(singleOption(args, "body"), singleOption(args, "file"));
1013
1318
  const body = bodySource.kind === "file"
1014
1319
  ? readConnectorBodyFile(bodySource.path)
1015
1320
  : bodySource.kind === "inline"
@@ -1824,6 +2129,8 @@ async function handleLocalApi(ctx, req, res, url) {
1824
2129
  }
1825
2130
  // Proxied surfaces → forward to the real instance with the saved token.
1826
2131
  if (path === "/connections" ||
2132
+ path === "/queries" ||
2133
+ path.startsWith("/queries/") ||
1827
2134
  path === "/sql" ||
1828
2135
  path === "/llm/generate" ||
1829
2136
  path === "/llm/stream" ||
@@ -2068,10 +2375,10 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
2068
2375
  }
2069
2376
  async function forwardCompute(ctx, req, res, path, url) {
2070
2377
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
2071
- // Load-time list calls degrade to [] so dataConnectors()/serviceConnectors() at
2072
- // load don't break; the actual call surfaces (/sql, /llm, /service-connectors/request)
2073
- // return 503 instead.
2074
- const degradeOk = path === "/connections" || path === "/service-connectors";
2378
+ // Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
2379
+ // serviceConnectors() at load don't break; actual call surfaces
2380
+ // (/queries/{name}, /sql, /llm, /service-connectors/request) return 503 instead.
2381
+ const degradeOk = path === "/connections" || path === "/queries" || path === "/service-connectors";
2075
2382
  const creds = devCreds(ctx.config);
2076
2383
  if (!creds) {
2077
2384
  if (degradeOk)
@@ -2081,8 +2388,8 @@ async function forwardCompute(ctx, req, res, path, url) {
2081
2388
  message: "Run `railcode login` to use LLM and connectors in dev.",
2082
2389
  });
2083
2390
  }
2084
- // Only the actual call surfaces (llm/sql/service-connectors/request) may create
2085
- // the app server-side; a load-time list (GET /connections, /service-connectors) won't.
2391
+ // Only the actual call surfaces (saved-query invoke / llm / sql /
2392
+ // service-connectors/request) may create the app server-side; load-time lists won't.
2086
2393
  const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
2087
2394
  if (!appUuid) {
2088
2395
  if (degradeOk)
package/dist/query.js ADDED
@@ -0,0 +1,94 @@
1
+ // Pure helpers behind `railcode query list|run|create|delete`. Kept free of any
2
+ // I/O or process state so they can be unit-tested directly (see
3
+ // test/query.test.mjs); the command layer in index.ts does the HTTP and printing.
4
+ export class QueryCmdError extends Error {
5
+ }
6
+ // Matches the backend's declared param types (schemas/saved_query.py).
7
+ export const QUERY_PARAM_TYPES = ["string", "int", "float", "bool"];
8
+ // `query run --params` — the caller params as ONE JSON object. The CLI is
9
+ // mostly driven by agents, and agents speak JSON natively: no name=value
10
+ // mini-grammar, the flag takes exactly what the API takes.
11
+ export function parseRunParams(json) {
12
+ if (json === undefined)
13
+ return {};
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(json);
17
+ }
18
+ catch {
19
+ throw new QueryCmdError(`--params must be valid JSON. Example: --params '{"region":"emea","limit":5}'`);
20
+ }
21
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
22
+ throw new QueryCmdError(`--params must be a JSON object, e.g. '{"region":"emea"}'.`);
23
+ }
24
+ return parsed;
25
+ }
26
+ // `query create|update --params` — the declared params as a JSON array in the
27
+ // exact API shape: [{"name","type","default"?}]. A "default" makes the param
28
+ // optional at invoke time (the server binds it when a caller omits the param);
29
+ // the backend re-validates it against the declared type at save.
30
+ export function parseParamDecls(json) {
31
+ if (json === undefined)
32
+ return [];
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(json);
36
+ }
37
+ catch {
38
+ throw new QueryCmdError(`--params must be valid JSON. Example: --params '[{"name":"region","type":"string","default":"emea"}]'`);
39
+ }
40
+ if (!Array.isArray(parsed)) {
41
+ throw new QueryCmdError(`--params must be a JSON array of {"name","type","default"?} objects.`);
42
+ }
43
+ return parsed.map((item, i) => {
44
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
45
+ throw new QueryCmdError(`--params[${i}] must be an object like {"name":"region","type":"string"}.`);
46
+ }
47
+ const { name, type, default: def, ...extra } = item;
48
+ if (typeof name !== "string" || name === "") {
49
+ throw new QueryCmdError(`--params[${i}] needs a non-empty string "name".`);
50
+ }
51
+ if (typeof type !== "string" || !QUERY_PARAM_TYPES.includes(type)) {
52
+ throw new QueryCmdError(`--params[${i}] ("${name}") needs "type": one of ${QUERY_PARAM_TYPES.join(", ")}.`);
53
+ }
54
+ const extraKeys = Object.keys(extra);
55
+ if (extraKeys.length > 0) {
56
+ throw new QueryCmdError(`--params[${i}] ("${name}") has unknown key(s): ${extraKeys.join(", ")}.`);
57
+ }
58
+ const decl = { name, type: type };
59
+ if (def !== undefined) {
60
+ if (def === null || !["string", "number", "boolean"].includes(typeof def)) {
61
+ throw new QueryCmdError(`--params[${i}] ("${name}") "default" must be a string, number or boolean — omit it to make the param required.`);
62
+ }
63
+ decl.default = def;
64
+ }
65
+ return decl;
66
+ });
67
+ }
68
+ export function pickAuthoringSql(sql, sqlFile, required) {
69
+ if (sql !== undefined && sqlFile !== undefined) {
70
+ throw new QueryCmdError("Pass the SQL template with --sql or --sql-file, not both.");
71
+ }
72
+ if (sql !== undefined) {
73
+ const trimmed = sql.trim();
74
+ if (!trimmed)
75
+ throw new QueryCmdError("--sql is empty.");
76
+ return { kind: "inline", sql: trimmed };
77
+ }
78
+ if (sqlFile !== undefined) {
79
+ return { kind: "file", path: sqlFile };
80
+ }
81
+ if (required) {
82
+ throw new QueryCmdError('Provide the SQL template: --sql "select ..." or --sql-file query.sql.');
83
+ }
84
+ return null;
85
+ }
86
+ // Render one saved query's signature for the list table, e.g.
87
+ // "region?: string = "emea", limit: int" — `?` marks optional (defaulted) params.
88
+ export function paramSignature(params) {
89
+ return (params
90
+ .map((p) => p.default === undefined
91
+ ? `${p.name}: ${p.type}`
92
+ : `${p.name}?: ${p.type} = ${JSON.stringify(p.default)}`)
93
+ .join(", ") || "-");
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Developer CLI for the multi-tenant Railcode platform: log in, scaffold, and deploy static apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/static/sdk.js CHANGED
@@ -120,10 +120,10 @@
120
120
  this.name = "ApiError";
121
121
  }
122
122
  };
123
- function buildUrl(path, query) {
123
+ function buildUrl(path, query2) {
124
124
  const url2 = new URL(BASE + path, location.origin);
125
- if (query) {
126
- for (const [key, value] of Object.entries(query)) {
125
+ if (query2) {
126
+ for (const [key, value] of Object.entries(query2)) {
127
127
  if (value !== void 0) url2.searchParams.set(key, String(value));
128
128
  }
129
129
  }
@@ -148,8 +148,8 @@
148
148
  if (resp.status === 204) return void 0;
149
149
  return await resp.json();
150
150
  }
151
- async function getOrNull(path, query) {
152
- const resp = await send(buildUrl(path, query), { credentials: "include" });
151
+ async function getOrNull(path, query2) {
152
+ const resp = await send(buildUrl(path, query2), { credentials: "include" });
153
153
  if (resp.status === 404) return null;
154
154
  if (!resp.ok) throw new ApiError(resp.status, await resp.text());
155
155
  return await resp.json();
@@ -232,8 +232,8 @@
232
232
  rows.truncated = r.truncated;
233
233
  return rows;
234
234
  };
235
- var runSQL = (engine, connection, query, params) => {
236
- const trimmed = shorten(query.replace(/\s+/g, " ").trim(), QUERY_LABEL_MAX);
235
+ var runSQL = (engine, connection, query2, params) => {
236
+ const trimmed = shorten(query2.replace(/\s+/g, " ").trim(), QUERY_LABEL_MAX);
237
237
  const label2 = `${engine ?? "data"}(${q(connection)}).runSQL(${q(trimmed)}${params && params.length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX)}` : ""})`;
238
238
  return track(
239
239
  "sql",
@@ -241,16 +241,16 @@
241
241
  () => call("POST", "/sql", {
242
242
  // engine is advisory; omit it for the generic data() namespace so the backend
243
243
  // dispatches on the connection's stored kind. postgres() pins engine="postgres".
244
- body: { ...engine ? { engine } : {}, connection, query, params: params || [] }
244
+ body: { ...engine ? { engine } : {}, connection, query: query2, params: params || [] }
245
245
  }).then(toSqlRows)
246
246
  );
247
247
  };
248
248
  var namespace = (engine) => {
249
249
  const handle = (connection) => ({
250
- runSQL: (query, params) => runSQL(engine, connection, query, params)
250
+ runSQL: (query2, params) => runSQL(engine, connection, query2, params)
251
251
  });
252
252
  const ns = handle;
253
- ns.runSQL = (query, params) => runSQL(engine, "default", query, params);
253
+ ns.runSQL = (query2, params) => runSQL(engine, "default", query2, params);
254
254
  return ns;
255
255
  };
256
256
  var data = namespace();
@@ -499,6 +499,20 @@
499
499
  }
500
500
  var llm = { generate, stream };
501
501
 
502
+ // ../sdk/src/queries.ts
503
+ var PARAMS_LABEL_MAX2 = 40;
504
+ var query = (name, params) => {
505
+ const label2 = `query(${q(name)}${params && Object.keys(params).length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX2)}` : ""})`;
506
+ return track(
507
+ "sql",
508
+ label2,
509
+ () => call("POST", `/queries/${encPath(name)}`, {
510
+ body: { params: params || {} }
511
+ }).then(toSqlRows)
512
+ );
513
+ };
514
+ var savedQueries = () => track("queries", "savedQueries()", () => call("GET", "/queries"));
515
+
502
516
  // ../sdk/src/service-connectors.ts
503
517
  var PATH_LABEL_MAX = 60;
504
518
  var toResponse = (env) => {
@@ -547,4 +561,6 @@
547
561
  target.dataConnectors = dataConnectors;
548
562
  target.connector = connector;
549
563
  target.serviceConnectors = serviceConnectors;
564
+ target.query = query;
565
+ target.savedQueries = savedQueries;
550
566
  })();