@quanthea/plugin-kit 0.1.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/LICENSE +21 -0
- package/README.md +63 -0
- package/checks.d.ts +13 -0
- package/chunk-3ayrhcmg.js +5 -0
- package/connector-kind.d.ts +128 -0
- package/errors.d.ts +34 -0
- package/frame-builder.d.ts +38 -0
- package/frames.d.ts +62 -0
- package/host.d.ts +12 -0
- package/http-address.d.ts +18 -0
- package/http.d.ts +91 -0
- package/index.d.ts +21 -0
- package/index.js +7 -0
- package/kit.d.ts +37 -0
- package/languages.d.ts +16 -0
- package/package.json +41 -0
- package/queries.d.ts +151 -0
- package/schema.d.ts +58 -0
- package/series-frames.d.ts +55 -0
- package/testing.d.ts +36 -0
- package/testing.js +642 -0
package/testing.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
kitVersion2
|
|
4
|
+
} from "./chunk-3ayrhcmg.js";
|
|
5
|
+
|
|
6
|
+
// packages/plugin-kit/src/testing.ts
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
// packages/plugin-kit/src/checks.ts
|
|
10
|
+
import { z as z2 } from "zod";
|
|
11
|
+
|
|
12
|
+
// packages/plugin-kit/src/languages.ts
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var queryLanguages = [
|
|
15
|
+
"sql",
|
|
16
|
+
"promql",
|
|
17
|
+
"search",
|
|
18
|
+
"logql",
|
|
19
|
+
"http",
|
|
20
|
+
"redis",
|
|
21
|
+
"mongodb"
|
|
22
|
+
];
|
|
23
|
+
var queryLanguageSchema = z.enum(queryLanguages);
|
|
24
|
+
|
|
25
|
+
// packages/plugin-kit/src/queries.ts
|
|
26
|
+
var sqlDialects = [
|
|
27
|
+
"postgres",
|
|
28
|
+
"mysql",
|
|
29
|
+
"clickhouse",
|
|
30
|
+
"trino",
|
|
31
|
+
"influxdb",
|
|
32
|
+
"ansi"
|
|
33
|
+
];
|
|
34
|
+
var sqlPlaceholderStyles = ["?", "$1", ":1", "@p1"];
|
|
35
|
+
var sqlRowLimits = ["fetch", "limit"];
|
|
36
|
+
|
|
37
|
+
// packages/plugin-kit/src/checks.ts
|
|
38
|
+
var kindPattern = /^[a-z][a-z0-9-]{0,39}$/;
|
|
39
|
+
var pathPattern = /^[MmZzLlHhVvCcSsQqTtAa0-9eE.,\s+-]+$/;
|
|
40
|
+
var colorPattern = /^#[0-9a-fA-F]{6}$/;
|
|
41
|
+
var maxGuideLength = 8192;
|
|
42
|
+
function oneOf(list, value) {
|
|
43
|
+
return typeof value === "string" && list.includes(value);
|
|
44
|
+
}
|
|
45
|
+
var namingChecks = [
|
|
46
|
+
[
|
|
47
|
+
(kind) => typeof kind.kind !== "string" || !kindPattern.test(kind.kind),
|
|
48
|
+
"kind must be lowercase letters, digits and dashes, up to 40 characters"
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
(kind) => typeof kind.displayName !== "string" || kind.displayName.trim() === "",
|
|
52
|
+
"displayName must be a name"
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
(kind) => {
|
|
56
|
+
const aliases = kind.aliases ?? [];
|
|
57
|
+
return !Array.isArray(aliases) || !aliases.every((alias) => typeof alias === "string");
|
|
58
|
+
},
|
|
59
|
+
"aliases must be a list of names"
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
(kind) => kind.queryGuide !== undefined && (typeof kind.queryGuide !== "string" || kind.queryGuide.length > maxGuideLength),
|
|
63
|
+
`queryGuide must be text of at most ${maxGuideLength} characters`
|
|
64
|
+
]
|
|
65
|
+
];
|
|
66
|
+
var languageChecks = [
|
|
67
|
+
[
|
|
68
|
+
(kind) => !oneOf(queryLanguages, kind.language),
|
|
69
|
+
`language must be one of ${queryLanguages.join(", ")}`
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
(kind) => kind.language === "sql" && !oneOf(sqlDialects, kind.dialect),
|
|
73
|
+
`a SQL kind's dialect must be one of ${sqlDialects.join(", ")}`
|
|
74
|
+
],
|
|
75
|
+
[
|
|
76
|
+
(kind) => kind.language !== "sql" && kind.dialect !== undefined,
|
|
77
|
+
"only a SQL kind declares a dialect"
|
|
78
|
+
],
|
|
79
|
+
[
|
|
80
|
+
(kind) => kind.placeholders !== undefined && !(kind.dialect === "ansi" && oneOf(sqlPlaceholderStyles, kind.placeholders)),
|
|
81
|
+
`placeholders is for ansi only: ${sqlPlaceholderStyles.join(", ")}`
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
(kind) => kind.rowLimit !== undefined && !(kind.dialect === "ansi" && oneOf(sqlRowLimits, kind.rowLimit)),
|
|
85
|
+
"rowLimit is for ansi only: fetch or limit"
|
|
86
|
+
]
|
|
87
|
+
];
|
|
88
|
+
function isIcon(icon) {
|
|
89
|
+
if (typeof icon !== "object" || icon === null)
|
|
90
|
+
return false;
|
|
91
|
+
const { path, color } = icon;
|
|
92
|
+
return typeof path === "string" && pathPattern.test(path) && typeof color === "string" && colorPattern.test(color);
|
|
93
|
+
}
|
|
94
|
+
var shapeChecks = [
|
|
95
|
+
[
|
|
96
|
+
(kind) => kind.icon !== undefined && !isIcon(kind.icon),
|
|
97
|
+
"icon must be SVG path data and a #rrggbb colour"
|
|
98
|
+
],
|
|
99
|
+
[(kind) => typeof kind.open !== "function", "open must be a function"],
|
|
100
|
+
[
|
|
101
|
+
(kind) => kind.describeTarget !== undefined && typeof kind.describeTarget !== "function",
|
|
102
|
+
"describeTarget must be a function"
|
|
103
|
+
]
|
|
104
|
+
];
|
|
105
|
+
function propertiesOf(schema) {
|
|
106
|
+
if (!(schema instanceof z2.ZodType))
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const json = z2.toJSONSchema(schema, { io: "input", unrepresentable: "any" });
|
|
110
|
+
return json.type === "object" ? Object.keys(json.properties ?? {}) : undefined;
|
|
111
|
+
} catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function schemaProblems(kind) {
|
|
116
|
+
const config = propertiesOf(kind.configSchema);
|
|
117
|
+
const secret = propertiesOf(kind.secretSchema);
|
|
118
|
+
const problems = [];
|
|
119
|
+
if (!config || config.length === 0)
|
|
120
|
+
problems.push("configSchema must be an object schema with fields, built with the kit's z");
|
|
121
|
+
if (!secret)
|
|
122
|
+
problems.push("secretSchema must be an object schema, built with the kit's z");
|
|
123
|
+
const shared = (config ?? []).filter((name) => (secret ?? []).includes(name));
|
|
124
|
+
if (shared.length > 0)
|
|
125
|
+
problems.push(`credentials in configSchema: ${shared.join(", ")}`);
|
|
126
|
+
return problems;
|
|
127
|
+
}
|
|
128
|
+
function kindProblems(kind) {
|
|
129
|
+
if (typeof kind !== "object" || kind === null)
|
|
130
|
+
return ["a kind must be an object"];
|
|
131
|
+
const candidate = kind;
|
|
132
|
+
const failed = [...namingChecks, ...languageChecks, ...shapeChecks].filter(([breaks]) => breaks(candidate)).map(([, message]) => message);
|
|
133
|
+
return [...failed, ...schemaProblems(candidate)];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// packages/plugin-kit/src/errors.ts
|
|
137
|
+
var connectorErrorCodes = [
|
|
138
|
+
"unreachable",
|
|
139
|
+
"authentication",
|
|
140
|
+
"permission",
|
|
141
|
+
"syntax",
|
|
142
|
+
"not_found",
|
|
143
|
+
"timeout",
|
|
144
|
+
"rejected",
|
|
145
|
+
"internal"
|
|
146
|
+
];
|
|
147
|
+
var brand = Symbol.for("quanthea.connector-error");
|
|
148
|
+
var knownCodes = new Set(connectorErrorCodes);
|
|
149
|
+
|
|
150
|
+
class ConnectorError extends Error {
|
|
151
|
+
code;
|
|
152
|
+
safeMessage;
|
|
153
|
+
constructor(code, safeMessage, message, options) {
|
|
154
|
+
super(message ?? safeMessage, options);
|
|
155
|
+
this.name = "ConnectorError";
|
|
156
|
+
this.code = code;
|
|
157
|
+
this.safeMessage = safeMessage;
|
|
158
|
+
}
|
|
159
|
+
static [Symbol.hasInstance](value) {
|
|
160
|
+
if (typeof value !== "object" || value === null)
|
|
161
|
+
return false;
|
|
162
|
+
const candidate = value;
|
|
163
|
+
return candidate[brand] === true && knownCodes.has(candidate.code) && typeof candidate.safeMessage === "string";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
Object.defineProperty(ConnectorError.prototype, brand, { value: true });
|
|
167
|
+
|
|
168
|
+
// packages/plugin-kit/src/frames.ts
|
|
169
|
+
import { z as z3 } from "zod";
|
|
170
|
+
var fieldTypes = ["time", "number", "string", "boolean"];
|
|
171
|
+
var fieldSchema = z3.object({
|
|
172
|
+
name: z3.string().min(1),
|
|
173
|
+
type: z3.enum(fieldTypes),
|
|
174
|
+
labels: z3.record(z3.string(), z3.string()).optional(),
|
|
175
|
+
unit: z3.string().optional()
|
|
176
|
+
});
|
|
177
|
+
var frameMetaSchema = z3.object({
|
|
178
|
+
rowCount: z3.number().int().nonnegative(),
|
|
179
|
+
truncated: z3.boolean(),
|
|
180
|
+
durationMs: z3.number().nonnegative()
|
|
181
|
+
});
|
|
182
|
+
var frameSchema = z3.object({
|
|
183
|
+
refId: z3.string().min(1),
|
|
184
|
+
name: z3.string().optional(),
|
|
185
|
+
fields: z3.array(fieldSchema),
|
|
186
|
+
values: z3.array(z3.array(z3.unknown())),
|
|
187
|
+
meta: frameMetaSchema
|
|
188
|
+
});
|
|
189
|
+
function fitsType(type, value) {
|
|
190
|
+
if (value === null)
|
|
191
|
+
return true;
|
|
192
|
+
if (type === "time" || type === "number") {
|
|
193
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
194
|
+
}
|
|
195
|
+
return typeof value === type;
|
|
196
|
+
}
|
|
197
|
+
function frameProblems(frame) {
|
|
198
|
+
const parsed = frameSchema.safeParse(frame);
|
|
199
|
+
if (!parsed.success) {
|
|
200
|
+
return parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
201
|
+
}
|
|
202
|
+
const { fields, values, meta } = parsed.data;
|
|
203
|
+
if (values.length !== fields.length) {
|
|
204
|
+
return [`${fields.length} fields but ${values.length} value columns`];
|
|
205
|
+
}
|
|
206
|
+
return fields.flatMap((field, index) => {
|
|
207
|
+
const column = values[index] ?? [];
|
|
208
|
+
if (column.length !== meta.rowCount) {
|
|
209
|
+
return [`field "${field.name}" has ${column.length} values for ${meta.rowCount} rows`];
|
|
210
|
+
}
|
|
211
|
+
const wrong = column.findIndex((value) => !fitsType(field.type, value));
|
|
212
|
+
return wrong === -1 ? [] : [`field "${field.name}" row ${wrong} is not a ${field.type}`];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// packages/plugin-kit/src/host.ts
|
|
217
|
+
import { z as z4 } from "zod";
|
|
218
|
+
|
|
219
|
+
// packages/plugin-kit/src/connector-kind.ts
|
|
220
|
+
var kindPattern2 = /^[a-z][a-z0-9-]*$/;
|
|
221
|
+
var pathPattern2 = /^[MmZzLlHhVvCcSsQqTtAa0-9eE.,\s+-]+$/;
|
|
222
|
+
var colorPattern2 = /^#[0-9a-fA-F]{6}$/;
|
|
223
|
+
function checkIcon(kind, icon) {
|
|
224
|
+
if (icon === undefined)
|
|
225
|
+
return;
|
|
226
|
+
if (!pathPattern2.test(icon.path) || !colorPattern2.test(icon.color))
|
|
227
|
+
throw new Error(`Connector kind "${kind}" has an icon that is not SVG path data and a colour.`);
|
|
228
|
+
}
|
|
229
|
+
function checkAnsiStyles(definition) {
|
|
230
|
+
const { kind, dialect, placeholders, rowLimit } = definition;
|
|
231
|
+
if (dialect !== "ansi" && (placeholders !== undefined || rowLimit !== undefined))
|
|
232
|
+
throw new Error(`Connector kind "${kind}" sets placeholders or rowLimit, which only the ansi dialect takes.`);
|
|
233
|
+
if (placeholders !== undefined && !sqlPlaceholderStyles.includes(placeholders))
|
|
234
|
+
throw new Error(`Connector kind "${kind}" has placeholders "${placeholders}": use ${sqlPlaceholderStyles.join(", ")}.`);
|
|
235
|
+
if (rowLimit !== undefined && !sqlRowLimits.includes(rowLimit))
|
|
236
|
+
throw new Error(`Connector kind "${kind}" has rowLimit "${rowLimit}": use fetch or limit.`);
|
|
237
|
+
}
|
|
238
|
+
function defineConnector(definition) {
|
|
239
|
+
if (!kindPattern2.test(definition.kind)) {
|
|
240
|
+
throw new Error(`Connector kind "${definition.kind}" must be lowercase letters, digits and dashes.`);
|
|
241
|
+
}
|
|
242
|
+
if (definition.language === "sql" && definition.dialect === undefined)
|
|
243
|
+
throw new Error(`Connector kind "${definition.kind}" runs SQL and must declare its dialect.`);
|
|
244
|
+
checkAnsiStyles(definition);
|
|
245
|
+
checkIcon(definition.kind, definition.icon);
|
|
246
|
+
return definition;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// packages/plugin-kit/src/frame-builder.ts
|
|
250
|
+
function createFrameBuilder(options) {
|
|
251
|
+
const columns = options.fields.map(() => []);
|
|
252
|
+
let rowCount = 0;
|
|
253
|
+
let truncated = false;
|
|
254
|
+
return {
|
|
255
|
+
add(row) {
|
|
256
|
+
if (rowCount >= options.maxRows) {
|
|
257
|
+
truncated = true;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
columns.forEach((column, index) => {
|
|
261
|
+
column.push(row[index] ?? null);
|
|
262
|
+
});
|
|
263
|
+
rowCount += 1;
|
|
264
|
+
return true;
|
|
265
|
+
},
|
|
266
|
+
build(durationMs) {
|
|
267
|
+
const frame = {
|
|
268
|
+
refId: options.refId,
|
|
269
|
+
fields: [...options.fields],
|
|
270
|
+
values: columns,
|
|
271
|
+
meta: { rowCount, truncated, durationMs: Math.round(durationMs) }
|
|
272
|
+
};
|
|
273
|
+
return options.name === undefined ? frame : { ...frame, name: options.name };
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// packages/plugin-kit/src/http-address.ts
|
|
279
|
+
var metadataIpv4 = [/^169\.254\./, /^100\.100\.100\.200$/];
|
|
280
|
+
var metadataIpv6 = [/^fe[89ab][0-9a-f]:/, /^fd00:ec2::254$/];
|
|
281
|
+
var dottedIpv4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
282
|
+
function mappedIpv4(address) {
|
|
283
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(address);
|
|
284
|
+
if (dotted)
|
|
285
|
+
return dotted[1];
|
|
286
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(address);
|
|
287
|
+
if (!hex)
|
|
288
|
+
return;
|
|
289
|
+
const high = Number.parseInt(hex[1] ?? "0", 16);
|
|
290
|
+
const low = Number.parseInt(hex[2] ?? "0", 16);
|
|
291
|
+
return [high >> 8, high & 255, low >> 8, low & 255].join(".");
|
|
292
|
+
}
|
|
293
|
+
function isMetadataAddress(address) {
|
|
294
|
+
const bare = address.replace(/^\[|\]$/g, "").toLowerCase();
|
|
295
|
+
const ipv4 = dottedIpv4.test(bare) ? bare : mappedIpv4(bare);
|
|
296
|
+
if (ipv4 !== undefined)
|
|
297
|
+
return metadataIpv4.some((pattern) => pattern.test(ipv4));
|
|
298
|
+
return metadataIpv6.some((pattern) => pattern.test(bare));
|
|
299
|
+
}
|
|
300
|
+
function isIpLiteral(host) {
|
|
301
|
+
return host.startsWith("[") || dottedIpv4.test(host);
|
|
302
|
+
}
|
|
303
|
+
async function checkDestination(url, sourceName) {
|
|
304
|
+
const refused = new ConnectorError("rejected", `The ${sourceName} URL points to a cloud metadata address, which connectors never call.`);
|
|
305
|
+
if (isMetadataAddress(url.hostname))
|
|
306
|
+
throw refused;
|
|
307
|
+
if (isIpLiteral(url.hostname))
|
|
308
|
+
return;
|
|
309
|
+
const addresses = await Bun.dns.lookup(url.hostname).catch(() => []);
|
|
310
|
+
if (addresses.some((entry) => isMetadataAddress(entry.address)))
|
|
311
|
+
throw refused;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// packages/plugin-kit/src/http.ts
|
|
315
|
+
var maxRedirects = 5;
|
|
316
|
+
function transportError(error, signal, sourceName) {
|
|
317
|
+
if (error instanceof ConnectorError)
|
|
318
|
+
return error;
|
|
319
|
+
if (signal.aborted) {
|
|
320
|
+
return new ConnectorError("timeout", "The query was cancelled: it ran longer than the timeout, or the caller gave up.");
|
|
321
|
+
}
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
return new ConnectorError("unreachable", `${sourceName} cannot be reached.`, message, {
|
|
324
|
+
cause: error
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function requestUrl(settings, request) {
|
|
328
|
+
const absolute = /^https?:\/\//i.test(request.path);
|
|
329
|
+
const url = new URL(absolute ? request.path : `${settings.base}${request.path}`);
|
|
330
|
+
if (url.origin !== settings.origin)
|
|
331
|
+
throw new ConnectorError("rejected", `A request left the ${settings.sourceName} origin.`);
|
|
332
|
+
const parameters = Object.entries(request.query ?? {}).flatMap(([name, value]) => (typeof value === "string" ? [value] : value).map((item) => [name, item]));
|
|
333
|
+
for (const [name, item] of parameters)
|
|
334
|
+
url.searchParams.append(name, item);
|
|
335
|
+
return url;
|
|
336
|
+
}
|
|
337
|
+
async function* chunksOf(body, settings, signal) {
|
|
338
|
+
if (!body)
|
|
339
|
+
return;
|
|
340
|
+
const reader = body.getReader();
|
|
341
|
+
let total = 0;
|
|
342
|
+
try {
|
|
343
|
+
for (let chunk = await reader.read();!chunk.done; chunk = await reader.read()) {
|
|
344
|
+
total += chunk.value.byteLength;
|
|
345
|
+
if (total > settings.maxBytes)
|
|
346
|
+
throw tooLarge(settings);
|
|
347
|
+
yield chunk.value;
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw transportError(error, signal, settings.sourceName);
|
|
351
|
+
} finally {
|
|
352
|
+
await reader.cancel().catch(() => {
|
|
353
|
+
return;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function tooLarge(settings) {
|
|
358
|
+
const mebibytes = Math.round(settings.maxBytes / 1024 / 1024);
|
|
359
|
+
return new ConnectorError("rejected", `${settings.sourceName} sent more than ${mebibytes} MiB. Ask for fewer rows or a shorter range.`);
|
|
360
|
+
}
|
|
361
|
+
async function* linesOf(chunks) {
|
|
362
|
+
const decoder = new TextDecoder;
|
|
363
|
+
let pending = "";
|
|
364
|
+
for await (const chunk of chunks) {
|
|
365
|
+
const parts = (pending + decoder.decode(chunk, { stream: true })).split(`
|
|
366
|
+
`);
|
|
367
|
+
pending = parts.pop() ?? "";
|
|
368
|
+
for (const line of parts)
|
|
369
|
+
yield line.replace(/\r$/, "");
|
|
370
|
+
}
|
|
371
|
+
pending += decoder.decode();
|
|
372
|
+
if (pending !== "")
|
|
373
|
+
yield pending.replace(/\r$/, "");
|
|
374
|
+
}
|
|
375
|
+
function wrapResponse(response, settings, signal) {
|
|
376
|
+
const text = async () => {
|
|
377
|
+
const decoder = new TextDecoder;
|
|
378
|
+
let result = "";
|
|
379
|
+
for await (const chunk of chunksOf(response.body, settings, signal))
|
|
380
|
+
result += decoder.decode(chunk, { stream: true });
|
|
381
|
+
return result + decoder.decode();
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
status: response.status,
|
|
385
|
+
ok: response.ok,
|
|
386
|
+
headers: response.headers,
|
|
387
|
+
text,
|
|
388
|
+
json: async () => {
|
|
389
|
+
const body = await text();
|
|
390
|
+
try {
|
|
391
|
+
return JSON.parse(body);
|
|
392
|
+
} catch {
|
|
393
|
+
throw new ConnectorError("internal", `${settings.sourceName} did not answer with JSON.`);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
lines: () => linesOf(chunksOf(response.body, settings, signal)),
|
|
397
|
+
cancel: async () => {
|
|
398
|
+
await response.body?.cancel().catch(() => {
|
|
399
|
+
return;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
function redirectTarget(response, url, settings) {
|
|
405
|
+
const location = response.headers.get("location");
|
|
406
|
+
if (response.status < 300 || response.status > 399 || location === null)
|
|
407
|
+
return;
|
|
408
|
+
const target = new URL(location, url);
|
|
409
|
+
if (target.origin === settings.origin)
|
|
410
|
+
return target;
|
|
411
|
+
throw new ConnectorError("rejected", `${settings.sourceName} redirected to another origin, which connectors never follow.`);
|
|
412
|
+
}
|
|
413
|
+
function fetchOnce(url, method, settings, request, signal) {
|
|
414
|
+
return fetch(url, {
|
|
415
|
+
method,
|
|
416
|
+
headers: { ...settings.headers, ...request.headers },
|
|
417
|
+
...method === "POST" ? { body: request.body } : {},
|
|
418
|
+
redirect: "manual",
|
|
419
|
+
signal,
|
|
420
|
+
tls: { rejectUnauthorized: settings.verifyTls }
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
async function send(settings, request, signal) {
|
|
424
|
+
let url = requestUrl(settings, request);
|
|
425
|
+
await checkDestination(url, settings.sourceName);
|
|
426
|
+
let method = request.method ?? "GET";
|
|
427
|
+
for (let redirects = 0;; redirects += 1) {
|
|
428
|
+
const response = await fetchOnce(url, method, settings, request, signal);
|
|
429
|
+
const target = redirectTarget(response, url, settings);
|
|
430
|
+
if (target === undefined || redirects === maxRedirects)
|
|
431
|
+
return response;
|
|
432
|
+
await response.body?.cancel();
|
|
433
|
+
if (response.status !== 307 && response.status !== 308)
|
|
434
|
+
method = "GET";
|
|
435
|
+
url = target;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function createHttpClient(options) {
|
|
439
|
+
const base = new URL(options.baseUrl);
|
|
440
|
+
const settings = {
|
|
441
|
+
base: base.href.replace(/\/+$/, ""),
|
|
442
|
+
origin: base.origin,
|
|
443
|
+
sourceName: options.sourceName,
|
|
444
|
+
headers: options.headers ?? {},
|
|
445
|
+
verifyTls: options.verifyTls ?? true,
|
|
446
|
+
timeoutMs: options.timeoutMs ?? 120000,
|
|
447
|
+
maxBytes: options.maxBytes ?? 64 * 1024 * 1024
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
async request(request) {
|
|
451
|
+
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(settings.timeoutMs)]);
|
|
452
|
+
try {
|
|
453
|
+
return wrapResponse(await send(settings, request, signal), settings, signal);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw transportError(error, signal, settings.sourceName);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// packages/plugin-kit/src/series-frames.ts
|
|
462
|
+
var maxSeries = 1000;
|
|
463
|
+
function sampleValue(value) {
|
|
464
|
+
const number = Number(value);
|
|
465
|
+
return Number.isFinite(number) ? number : null;
|
|
466
|
+
}
|
|
467
|
+
function seriesName(metric) {
|
|
468
|
+
const { __name__: name = "", ...labels } = metric;
|
|
469
|
+
const pairs = Object.entries(labels).map(([key, value]) => `${key}="${value}"`);
|
|
470
|
+
return `${name}{${pairs.join(", ")}}`;
|
|
471
|
+
}
|
|
472
|
+
function matrixFrames(result, context, durationMs) {
|
|
473
|
+
const frames = result.slice(0, maxSeries).map((series) => {
|
|
474
|
+
const { __name__: _name, ...labels } = series.metric;
|
|
475
|
+
const fields = [
|
|
476
|
+
{ name: "time", type: "time" },
|
|
477
|
+
{ name: "Value", type: "number", labels }
|
|
478
|
+
];
|
|
479
|
+
const builder = createFrameBuilder({
|
|
480
|
+
refId: context.refId,
|
|
481
|
+
fields,
|
|
482
|
+
maxRows: context.maxRows,
|
|
483
|
+
name: seriesName(series.metric)
|
|
484
|
+
});
|
|
485
|
+
for (const [seconds, value] of series.values) {
|
|
486
|
+
if (!builder.add([seconds * 1000, sampleValue(value)]))
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
return builder.build(durationMs);
|
|
490
|
+
});
|
|
491
|
+
return markDroppedSeries(frames, result.length);
|
|
492
|
+
}
|
|
493
|
+
function markDroppedSeries(frames, seriesCount) {
|
|
494
|
+
const last = frames.at(-1);
|
|
495
|
+
if (seriesCount <= frames.length || !last)
|
|
496
|
+
return frames;
|
|
497
|
+
return [...frames.slice(0, -1), { ...last, meta: { ...last.meta, truncated: true } }];
|
|
498
|
+
}
|
|
499
|
+
function vectorFrame(result, context, durationMs) {
|
|
500
|
+
const labelNames = [...new Set(result.flatMap((sample) => Object.keys(sample.metric)))].filter((name) => name !== "__name__").sort();
|
|
501
|
+
const fields = [
|
|
502
|
+
...labelNames.map((name) => ({ name, type: "string" })),
|
|
503
|
+
{ name: "Value", type: "number" }
|
|
504
|
+
];
|
|
505
|
+
const builder = createFrameBuilder({ refId: context.refId, fields, maxRows: context.maxRows });
|
|
506
|
+
for (const sample of result) {
|
|
507
|
+
const row = [
|
|
508
|
+
...labelNames.map((name) => sample.metric[name] ?? null),
|
|
509
|
+
sampleValue(sample.value[1])
|
|
510
|
+
];
|
|
511
|
+
if (!builder.add(row))
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
514
|
+
return builder.build(durationMs);
|
|
515
|
+
}
|
|
516
|
+
function seriesFrames(data, context, durationMs) {
|
|
517
|
+
if (data.resultType === "matrix")
|
|
518
|
+
return matrixFrames(data.result, context, durationMs);
|
|
519
|
+
if (data.resultType === "vector")
|
|
520
|
+
return [vectorFrame(data.result, context, durationMs)];
|
|
521
|
+
const [seconds, value] = data.result;
|
|
522
|
+
const valueType = data.resultType === "scalar" ? "number" : "string";
|
|
523
|
+
const builder = createFrameBuilder({
|
|
524
|
+
refId: context.refId,
|
|
525
|
+
fields: [
|
|
526
|
+
{ name: "time", type: "time" },
|
|
527
|
+
{ name: "Value", type: valueType }
|
|
528
|
+
],
|
|
529
|
+
maxRows: context.maxRows
|
|
530
|
+
});
|
|
531
|
+
builder.add([seconds * 1000, valueType === "number" ? sampleValue(value) : value]);
|
|
532
|
+
return [builder.build(durationMs)];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// packages/plugin-kit/src/host.ts
|
|
536
|
+
var hostKit = Object.freeze({
|
|
537
|
+
version: kitVersion2,
|
|
538
|
+
z: z4,
|
|
539
|
+
defineConnector,
|
|
540
|
+
ConnectorError,
|
|
541
|
+
createFrameBuilder,
|
|
542
|
+
createHttpClient,
|
|
543
|
+
seriesFrames
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// packages/plugin-kit/src/testing.ts
|
|
547
|
+
function createTestKit() {
|
|
548
|
+
return hostKit;
|
|
549
|
+
}
|
|
550
|
+
function contextFor(fixture, overrides = {}) {
|
|
551
|
+
return {
|
|
552
|
+
refId: "A",
|
|
553
|
+
signal: AbortSignal.timeout(1e4),
|
|
554
|
+
timeoutMs: 1e4,
|
|
555
|
+
maxRows: 1000,
|
|
556
|
+
timeRange: fixture.timeRange,
|
|
557
|
+
...overrides
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function testDeclaration(kind, fixture) {
|
|
561
|
+
test("passes the static checks the loader runs on every plugin", () => {
|
|
562
|
+
expect(kindProblems(kind)).toEqual([]);
|
|
563
|
+
});
|
|
564
|
+
test("accepts the fixture configuration and credentials", () => {
|
|
565
|
+
expect(kind.configSchema.safeParse(fixture.config).success).toBe(true);
|
|
566
|
+
expect(kind.secretSchema.safeParse(fixture.secret).success).toBe(true);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function testReading(connection, fixture) {
|
|
570
|
+
test("test() reports a healthy source", async () => {
|
|
571
|
+
const report = await connection().test(AbortSignal.timeout(1e4));
|
|
572
|
+
expect(report.ok).toBe(true);
|
|
573
|
+
expect(report.latencyMs).toBeGreaterThanOrEqual(0);
|
|
574
|
+
expect([true, false, null]).toContain(report.readOnly);
|
|
575
|
+
});
|
|
576
|
+
test("describe() lists entities with unique names and their fields", async () => {
|
|
577
|
+
const snapshot = await connection().describe(AbortSignal.timeout(1e4));
|
|
578
|
+
const names = snapshot.entities.map((entity) => entity.name);
|
|
579
|
+
expect(names.length).toBeGreaterThan(0);
|
|
580
|
+
expect(new Set(names).size).toBe(names.length);
|
|
581
|
+
expect(snapshot.entities.every((entity) => Array.isArray(entity.fields))).toBe(true);
|
|
582
|
+
expect(snapshot.entities.some((entity) => entity.fields.length > 0)).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
test("sampleValues() returns no more values than asked", async () => {
|
|
585
|
+
const sample = await connection().sampleValues(fixture.sampleField, 1, AbortSignal.timeout(1e4));
|
|
586
|
+
expect(sample.values.length).toBeLessThanOrEqual(1);
|
|
587
|
+
expect(sample.complete).toBe(false);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
function testExecution(connection, fixture) {
|
|
591
|
+
test("execute() returns valid frames named after the refId", async () => {
|
|
592
|
+
const frames = await connection().execute(fixture.query, contextFor(fixture, { refId: "Q" }));
|
|
593
|
+
expect(frames.length).toBeGreaterThan(0);
|
|
594
|
+
frames.forEach((frame) => {
|
|
595
|
+
expect(frameProblems(frame)).toEqual([]);
|
|
596
|
+
expect(frame.refId).toBe("Q");
|
|
597
|
+
});
|
|
598
|
+
expect(frames.reduce((rows, frame) => rows + frame.meta.rowCount, 0)).toBeGreaterThanOrEqual(2);
|
|
599
|
+
});
|
|
600
|
+
test("execute() stops at maxRows and marks the frame truncated", async () => {
|
|
601
|
+
const frames = await connection().execute(fixture.query, contextFor(fixture, { maxRows: 1 }));
|
|
602
|
+
frames.forEach((frame) => {
|
|
603
|
+
expect(frame.meta.rowCount).toBeLessThanOrEqual(1);
|
|
604
|
+
});
|
|
605
|
+
expect(frames.some((frame) => frame.meta.truncated)).toBe(true);
|
|
606
|
+
});
|
|
607
|
+
test("execute() rejects with a ConnectorError when the signal is already aborted", async () => {
|
|
608
|
+
const signal = AbortSignal.abort();
|
|
609
|
+
const failure = await connection().execute(fixture.query, contextFor(fixture, { signal })).catch((error) => error);
|
|
610
|
+
expect(failure).toBeInstanceOf(ConnectorError);
|
|
611
|
+
});
|
|
612
|
+
test("execute() reports a failed query as a ConnectorError with a safe message", async () => {
|
|
613
|
+
const failure = await connection().execute(fixture.invalidQuery, contextFor(fixture)).catch((error) => error);
|
|
614
|
+
expect(failure).toBeInstanceOf(ConnectorError);
|
|
615
|
+
expect(failure.safeMessage.length).toBeGreaterThan(0);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function testConnectorConformance(kind, fixture) {
|
|
619
|
+
const name = fixture.label ? `${kind.kind}" on "${fixture.label}` : kind.kind;
|
|
620
|
+
describe(`connector kind "${name}": declaration`, () => testDeclaration(kind, fixture));
|
|
621
|
+
describe.skipIf(!fixture.live)(`connector kind "${name}": live source`, () => {
|
|
622
|
+
let instance;
|
|
623
|
+
const connection = () => {
|
|
624
|
+
if (!instance)
|
|
625
|
+
throw new Error("The connection is not open.");
|
|
626
|
+
return instance;
|
|
627
|
+
};
|
|
628
|
+
beforeAll(() => {
|
|
629
|
+
instance = kind.open({
|
|
630
|
+
config: kind.configSchema.parse(fixture.config),
|
|
631
|
+
secret: kind.secretSchema.parse(fixture.secret)
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
afterAll(() => instance?.close());
|
|
635
|
+
testReading(connection, fixture);
|
|
636
|
+
testExecution(connection, fixture);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
export {
|
|
640
|
+
createTestKit,
|
|
641
|
+
testConnectorConformance
|
|
642
|
+
};
|