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 +322 -15
- package/dist/query.js +94 -0
- package/package.json +1 -1
- package/static/sdk.js +26 -10
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
|
|
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
|
|
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
|
|
904
|
+
const source = pickSqlSource(args.rest[1], singleOption(args, "file"));
|
|
860
905
|
const params = parseSqlParams(optionString(args, "params"));
|
|
861
|
-
const engineOption = parseEngine(args
|
|
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
|
|
1011
|
-
const method = parseMethodOption(args
|
|
1012
|
-
const bodySource = pickConnectorBody(args
|
|
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()/
|
|
2072
|
-
// load don't break;
|
|
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/
|
|
2085
|
-
// the app server-side;
|
|
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
package/static/sdk.js
CHANGED
|
@@ -120,10 +120,10 @@
|
|
|
120
120
|
this.name = "ApiError";
|
|
121
121
|
}
|
|
122
122
|
};
|
|
123
|
-
function buildUrl(path,
|
|
123
|
+
function buildUrl(path, query2) {
|
|
124
124
|
const url2 = new URL(BASE + path, location.origin);
|
|
125
|
-
if (
|
|
126
|
-
for (const [key, value] of Object.entries(
|
|
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,
|
|
152
|
-
const resp = await send(buildUrl(path,
|
|
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,
|
|
236
|
-
const trimmed = shorten(
|
|
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: (
|
|
250
|
+
runSQL: (query2, params) => runSQL(engine, connection, query2, params)
|
|
251
251
|
});
|
|
252
252
|
const ns = handle;
|
|
253
|
-
ns.runSQL = (
|
|
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
|
})();
|