@deepexi/datasense-connector 0.0.0-stage → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -2
- package/cjs/index.cjs +1015 -0
- package/cjs/runtime.cjs +752 -0
- package/cjs/testing.cjs +3591 -0
- package/errors.d.ts +17 -0
- package/errors.js +53 -0
- package/guest.cjs +2659 -0
- package/index.d.ts +13 -0
- package/index.js +7 -0
- package/internal/location.d.ts +6 -0
- package/internal/location.js +8 -0
- package/package.json +42 -4
- package/presentation.d.ts +38 -0
- package/presentation.js +268 -0
- package/runtime/guest/bridge.d.ts +53 -0
- package/runtime/guest/bridge.js +187 -0
- package/runtime/guest/cache.d.ts +3 -0
- package/runtime/guest/cache.js +42 -0
- package/runtime/guest/crypto.d.ts +13 -0
- package/runtime/guest/crypto.js +177 -0
- package/runtime/guest/ctx.d.ts +30 -0
- package/runtime/guest/ctx.js +124 -0
- package/runtime/guest/db.d.ts +6 -0
- package/runtime/guest/db.js +59 -0
- package/runtime/guest/discover.d.ts +29 -0
- package/runtime/guest/discover.js +51 -0
- package/runtime/guest/failure.d.ts +24 -0
- package/runtime/guest/failure.js +63 -0
- package/runtime/guest/http.d.ts +4 -0
- package/runtime/guest/http.js +204 -0
- package/runtime/guest/index.d.ts +12 -0
- package/runtime/guest/index.js +89 -0
- package/runtime/guest/limits.d.ts +20 -0
- package/runtime/guest/limits.js +12 -0
- package/runtime/guest/loader.d.ts +15 -0
- package/runtime/guest/loader.js +42 -0
- package/runtime/guest/log.d.ts +10 -0
- package/runtime/guest/log.js +36 -0
- package/runtime/guest/map.d.ts +4 -0
- package/runtime/guest/map.js +45 -0
- package/runtime/guest/paginate.d.ts +6 -0
- package/runtime/guest/paginate.js +180 -0
- package/runtime/guest/pull.d.ts +12 -0
- package/runtime/guest/pull.js +150 -0
- package/runtime/guest/push.d.ts +18 -0
- package/runtime/guest/push.js +80 -0
- package/runtime/guest/signal.d.ts +3 -0
- package/runtime/guest/signal.js +12 -0
- package/runtime/guest/time.d.ts +5 -0
- package/runtime/guest/time.js +127 -0
- package/runtime/guest-runtime.d.ts +7 -0
- package/runtime/guest-runtime.js +51 -0
- package/runtime/index.d.ts +18 -0
- package/runtime/index.js +3 -0
- package/runtime/protocol.d.ts +276 -0
- package/runtime/protocol.js +409 -0
- package/runtime/session.d.ts +12 -0
- package/runtime/session.js +191 -0
- package/t.d.ts +18 -0
- package/t.js +26 -0
- package/testing/child.cjs +350 -0
- package/testing/child.d.ts +1 -0
- package/testing/child.js +12 -0
- package/testing/compliance.d.ts +41 -0
- package/testing/compliance.js +291 -0
- package/testing/engine.d.ts +66 -0
- package/testing/engine.js +339 -0
- package/testing/fixtures.d.ts +145 -0
- package/testing/fixtures.js +407 -0
- package/testing/gateway.d.ts +65 -0
- package/testing/gateway.js +402 -0
- package/testing/import-guard.d.ts +57 -0
- package/testing/import-guard.js +187 -0
- package/testing/index.d.ts +33 -0
- package/testing/index.js +10 -0
- package/testing/local-db.d.ts +27 -0
- package/testing/local-db.js +181 -0
- package/testing/pinned-http.d.ts +14 -0
- package/testing/pinned-http.js +125 -0
- package/testing/recorder.d.ts +15 -0
- package/testing/recorder.js +116 -0
- package/testing/redaction.d.ts +28 -0
- package/testing/redaction.js +67 -0
- package/testing/replay.d.ts +50 -0
- package/testing/replay.js +313 -0
- package/testing/schema.d.ts +13 -0
- package/testing/schema.js +65 -0
- package/testing/source-loader.d.ts +19 -0
- package/testing/source-loader.js +238 -0
- package/testing/types.d.ts +79 -0
- package/testing/types.js +18 -0
- package/types.d.ts +302 -0
- package/types.js +13 -0
- package/validate.d.ts +78 -0
- package/validate.js +597 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { errors } from '../../errors.js';
|
|
2
|
+
import { HostCallFailure, call, jsonParse, jsonStringify, now, sleep, throwIfAborted } from './bridge.js';
|
|
3
|
+
import { bytesToBase64 } from './crypto.js';
|
|
4
|
+
import { MAX_RETRY_WAIT_MS, RATE_LIMIT_BACKOFF_MS, RATE_LIMIT_RETRIES, TRANSIENT_BACKOFF_MS, TRANSIENT_RETRIES } from './limits.js';
|
|
5
|
+
const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'];
|
|
6
|
+
const ABSOLUTE_URL = /^https?:\/\//i;
|
|
7
|
+
const TRANSIENT_STATUS = [502, 503, 504];
|
|
8
|
+
const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'PUT', 'DELETE'];
|
|
9
|
+
const JSON_CONTENT_TYPE = /(?:^|[/+])json\s*(?:;|$)/i;
|
|
10
|
+
function isRecord(value) {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function stringify(value) {
|
|
14
|
+
if (typeof value === 'string')
|
|
15
|
+
return value;
|
|
16
|
+
if (value instanceof Date)
|
|
17
|
+
return value.toISOString();
|
|
18
|
+
if (typeof value === 'object' && value !== null)
|
|
19
|
+
return jsonStringify(value);
|
|
20
|
+
return String(value);
|
|
21
|
+
}
|
|
22
|
+
export function resolveUrl(url, baseUrl) {
|
|
23
|
+
if (ABSOLUTE_URL.test(url))
|
|
24
|
+
return url;
|
|
25
|
+
if (!baseUrl)
|
|
26
|
+
throw new TypeError(`relative URL ${url} needs auth.baseUrl or a fixed host in the connector's hosts`);
|
|
27
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
28
|
+
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
|
29
|
+
}
|
|
30
|
+
function buildHeaders(options) {
|
|
31
|
+
const headers = {};
|
|
32
|
+
for (const source of [options.auth?.headers, options.headers]) {
|
|
33
|
+
if (source === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
if (!isRecord(source))
|
|
36
|
+
throw new TypeError('headers must be an object');
|
|
37
|
+
for (const [name, value] of Object.entries(source)) {
|
|
38
|
+
if (value === undefined || value === null)
|
|
39
|
+
continue;
|
|
40
|
+
headers[name.toLowerCase()] = stringify(value);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
45
|
+
function buildQuery(query) {
|
|
46
|
+
const out = {};
|
|
47
|
+
if (query === undefined)
|
|
48
|
+
return out;
|
|
49
|
+
if (!isRecord(query))
|
|
50
|
+
throw new TypeError('query must be an object');
|
|
51
|
+
for (const [name, value] of Object.entries(query)) {
|
|
52
|
+
if (value === undefined || value === null)
|
|
53
|
+
continue;
|
|
54
|
+
out[name] = Array.isArray(value) ? value.filter((item) => item !== undefined && item !== null).map(stringify) : stringify(value);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function buildBody(body, headers) {
|
|
59
|
+
if (body === undefined || body === null)
|
|
60
|
+
return { body: null, bodyEncoding: 'utf8' };
|
|
61
|
+
if (typeof body === 'string')
|
|
62
|
+
return { body, bodyEncoding: 'utf8' };
|
|
63
|
+
if (body instanceof Uint8Array)
|
|
64
|
+
return { body: bytesToBase64(Array.from(body)), bodyEncoding: 'base64' };
|
|
65
|
+
if (body instanceof ArrayBuffer)
|
|
66
|
+
return { body: bytesToBase64(Array.from(new Uint8Array(body))), bodyEncoding: 'base64' };
|
|
67
|
+
const text = jsonStringify(body);
|
|
68
|
+
if (typeof text !== 'string')
|
|
69
|
+
throw new TypeError('request body is not serializable');
|
|
70
|
+
if (headers['content-type'] === undefined)
|
|
71
|
+
headers['content-type'] = 'application/json';
|
|
72
|
+
return { body: text, bodyEncoding: 'utf8' };
|
|
73
|
+
}
|
|
74
|
+
function buildRequest(method, url, options, defaultBaseUrl) {
|
|
75
|
+
const upper = typeof method === 'string' ? method.toUpperCase() : '';
|
|
76
|
+
if (!METHODS.includes(upper))
|
|
77
|
+
throw new TypeError(`unsupported HTTP method ${String(method)}`);
|
|
78
|
+
if (typeof url !== 'string' || url.length === 0)
|
|
79
|
+
throw new TypeError('url must be a non-empty string');
|
|
80
|
+
if (!isRecord(options))
|
|
81
|
+
throw new TypeError('options must be an object');
|
|
82
|
+
const baseUrl = options.auth?.baseUrl ?? defaultBaseUrl;
|
|
83
|
+
const headers = buildHeaders(options);
|
|
84
|
+
const request = {
|
|
85
|
+
method: upper,
|
|
86
|
+
url: resolveUrl(url, typeof baseUrl === 'string' ? baseUrl : null),
|
|
87
|
+
headers,
|
|
88
|
+
query: buildQuery(options.query),
|
|
89
|
+
...buildBody(options.body, headers),
|
|
90
|
+
};
|
|
91
|
+
if (options.retry !== undefined && typeof options.retry !== 'boolean')
|
|
92
|
+
throw new TypeError('retry must be a boolean');
|
|
93
|
+
if (options.timeoutMs !== undefined) {
|
|
94
|
+
if (typeof options.timeoutMs !== 'number' || !(options.timeoutMs > 0))
|
|
95
|
+
throw new TypeError('timeoutMs must be a positive number');
|
|
96
|
+
request.timeoutMs = options.timeoutMs;
|
|
97
|
+
}
|
|
98
|
+
return request;
|
|
99
|
+
}
|
|
100
|
+
function toResponse(value, parseJson) {
|
|
101
|
+
if (!isRecord(value) || typeof value.status !== 'number' || typeof value.body !== 'string')
|
|
102
|
+
throw new TypeError('http host call returned a malformed response');
|
|
103
|
+
const raw = value;
|
|
104
|
+
const headers = {};
|
|
105
|
+
if (isRecord(raw.headers)) {
|
|
106
|
+
for (const [name, item] of Object.entries(raw.headers))
|
|
107
|
+
if (typeof item === 'string')
|
|
108
|
+
headers[name.toLowerCase()] = item;
|
|
109
|
+
}
|
|
110
|
+
const text = raw.body;
|
|
111
|
+
const url = typeof raw.url === 'string' ? raw.url : '';
|
|
112
|
+
let data = text;
|
|
113
|
+
if (parseJson && raw.bodyEncoding !== 'base64' && JSON_CONTENT_TYPE.test(headers['content-type'] ?? '') && text.length > 0) {
|
|
114
|
+
try {
|
|
115
|
+
data = jsonParse(text);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
throw new errors.FatalError(`response from ${url} is not valid JSON`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { status: raw.status, url, headers, data, text };
|
|
122
|
+
}
|
|
123
|
+
function retryAfterMs(headers) {
|
|
124
|
+
const value = headers['retry-after'];
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
const trimmed = value.trim();
|
|
128
|
+
if (/^\d+(?:\.\d+)?$/.test(trimmed))
|
|
129
|
+
return Number(trimmed) * 1000;
|
|
130
|
+
const at = Date.parse(trimmed);
|
|
131
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now());
|
|
132
|
+
}
|
|
133
|
+
function clampWait(ms) {
|
|
134
|
+
return Math.min(Math.max(0, ms), MAX_RETRY_WAIT_MS);
|
|
135
|
+
}
|
|
136
|
+
export function createHttp(defaultBaseUrl) {
|
|
137
|
+
async function request(method, url, options = {}) {
|
|
138
|
+
const req = buildRequest(method, url, options, defaultBaseUrl);
|
|
139
|
+
const raw = options.raw === true;
|
|
140
|
+
const label = `${req.method} ${req.url}`;
|
|
141
|
+
const retryTransient = options.retry ?? IDEMPOTENT_METHODS.includes(req.method);
|
|
142
|
+
const transientLimit = retryTransient ? TRANSIENT_RETRIES : 0;
|
|
143
|
+
let rateRetries = 0;
|
|
144
|
+
let transientRetries = 0;
|
|
145
|
+
for (;;) {
|
|
146
|
+
throwIfAborted();
|
|
147
|
+
let value;
|
|
148
|
+
try {
|
|
149
|
+
value = await call('http', req);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof HostCallFailure && error.code === 'EGRESS_RATE_LIMITED') {
|
|
153
|
+
const wait = error.retryAfterMs ?? RATE_LIMIT_BACKOFF_MS * 2 ** rateRetries;
|
|
154
|
+
if (rateRetries >= RATE_LIMIT_RETRIES)
|
|
155
|
+
throw new errors.RateLimited(wait, `${label} is still rate limited after ${RATE_LIMIT_RETRIES} retries`);
|
|
156
|
+
rateRetries += 1;
|
|
157
|
+
await sleep(clampWait(wait));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (error instanceof HostCallFailure && error.code === 'EGRESS_TIMEOUT') {
|
|
161
|
+
if (transientRetries >= transientLimit)
|
|
162
|
+
throw new errors.TransientError(`${label} timed out after ${transientRetries} retries`);
|
|
163
|
+
await sleep(TRANSIENT_BACKOFF_MS * 2 ** transientRetries);
|
|
164
|
+
transientRetries += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
const response = toResponse(value, !raw);
|
|
170
|
+
if (raw)
|
|
171
|
+
return response;
|
|
172
|
+
const status = response.status;
|
|
173
|
+
if (status === 429) {
|
|
174
|
+
const wait = retryAfterMs(response.headers) ?? RATE_LIMIT_BACKOFF_MS * 2 ** rateRetries;
|
|
175
|
+
if (rateRetries >= RATE_LIMIT_RETRIES)
|
|
176
|
+
throw new errors.RateLimited(wait, `${label} returned 429 after ${RATE_LIMIT_RETRIES} retries`);
|
|
177
|
+
rateRetries += 1;
|
|
178
|
+
await sleep(clampWait(wait));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (TRANSIENT_STATUS.includes(status)) {
|
|
182
|
+
if (transientRetries >= transientLimit)
|
|
183
|
+
throw new errors.TransientError(`${label} returned ${status} after ${transientRetries} retries`);
|
|
184
|
+
await sleep(TRANSIENT_BACKOFF_MS * 2 ** transientRetries);
|
|
185
|
+
transientRetries += 1;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (status === 401)
|
|
189
|
+
throw new errors.AuthError(`${label} returned 401`);
|
|
190
|
+
if (status >= 400)
|
|
191
|
+
throw new errors.FatalError(`${label} returned ${status}`);
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const verb = (method) => (url, options) => request(method, url, options);
|
|
196
|
+
return Object.freeze({
|
|
197
|
+
get: verb('GET'),
|
|
198
|
+
post: verb('POST'),
|
|
199
|
+
put: verb('PUT'),
|
|
200
|
+
patch: verb('PATCH'),
|
|
201
|
+
delete: verb('DELETE'),
|
|
202
|
+
request,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface GuestApi {
|
|
2
|
+
/** 只生效一次:`json` 为 `{ expect, job }`,`factory` 为上下文里编译出的 `(function (module, exports, require) {…})`。 */
|
|
3
|
+
start(json: string, factory: unknown): void;
|
|
4
|
+
handle(json: string): void;
|
|
5
|
+
poll(): string;
|
|
6
|
+
}
|
|
7
|
+
export declare function bootGuest(): GuestApi;
|
|
8
|
+
/**
|
|
9
|
+
* 进程内执行(`./testing` 的 `createInProcessExecutor`,给 vitest 用):每次执行都重新求值一份 guest 运行时,
|
|
10
|
+
* 这里的全局对象是宿主进程自己的,所以不拿掉任何全局;其余与 `bootGuest` 相同。
|
|
11
|
+
*/
|
|
12
|
+
export declare function bootLocalGuest(): GuestApi;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { summarizeDefinition, validateConfig } from '../../validate.js';
|
|
2
|
+
import { call, finish, handle, jsonParse, jsonStringify, poll } from './bridge.js';
|
|
3
|
+
import { concurrencyFromDefinitionReply, prepareSecrets } from './ctx.js';
|
|
4
|
+
import { discoverDeclaration, runCheck, runDiscover } from './discover.js';
|
|
5
|
+
import { connectorStack, errorInfo, failure, failureFromError } from './failure.js';
|
|
6
|
+
import { loadDefinition } from './loader.js';
|
|
7
|
+
import { createLogBuffer } from './log.js';
|
|
8
|
+
import { runPull } from './pull.js';
|
|
9
|
+
import { runPush } from './push.js';
|
|
10
|
+
const STRIPPED_GLOBALS = ['WebAssembly', 'ShadowRealm', 'console'];
|
|
11
|
+
function issuesText(issues) {
|
|
12
|
+
return issues.map((issue) => `${issue.path || '(root)'}: ${issue.message}`).join('; ');
|
|
13
|
+
}
|
|
14
|
+
async function run(definition, job) {
|
|
15
|
+
if (job.kind === 'push')
|
|
16
|
+
return runPush(definition, job);
|
|
17
|
+
if (job.kind === 'pull' && !(definition.streams && Object.hasOwn(definition.streams, job.stream))) {
|
|
18
|
+
return failure('CONNECTOR_LOAD_FAILED', `connector has no stream named ${job.stream}`, []);
|
|
19
|
+
}
|
|
20
|
+
const summary = jsonParse(jsonStringify(summarizeDefinition(definition)));
|
|
21
|
+
const logs = createLogBuffer();
|
|
22
|
+
let effectiveConcurrency;
|
|
23
|
+
try {
|
|
24
|
+
effectiveConcurrency = concurrencyFromDefinitionReply(await call('definition', summary));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
return failureFromError(error, logs.drain());
|
|
28
|
+
}
|
|
29
|
+
const issues = validateConfig(definition.config, job.config);
|
|
30
|
+
if (issues.length > 0)
|
|
31
|
+
return failure('CONFIG_INVALID', `invalid connection config: ${issuesText(issues)}`, logs.drain());
|
|
32
|
+
let secrets;
|
|
33
|
+
try {
|
|
34
|
+
secrets = await prepareSecrets(summary, job.secretFields);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
return failureFromError(error, logs.drain());
|
|
38
|
+
}
|
|
39
|
+
const options = { config: job.config, secrets, summary, logs, effectiveConcurrency };
|
|
40
|
+
if (job.kind === 'discover')
|
|
41
|
+
return runDiscover(definition, options);
|
|
42
|
+
if (job.kind === 'check')
|
|
43
|
+
return runCheck(definition, options);
|
|
44
|
+
if (job.discoverFirst !== true)
|
|
45
|
+
return runPull(definition, job, options);
|
|
46
|
+
const declared = await discoverDeclaration(definition, options);
|
|
47
|
+
if (!declared.ok)
|
|
48
|
+
return declared.failure;
|
|
49
|
+
try {
|
|
50
|
+
await call('declaration', { summary, discover: declared.discover });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
return failureFromError(error, logs.drain());
|
|
54
|
+
}
|
|
55
|
+
const stream = Object.hasOwn(declared.discover.streams, job.stream) ? declared.discover.streams[job.stream] : undefined;
|
|
56
|
+
if (!stream)
|
|
57
|
+
return failure('SCHEMA_ERROR', `stream ${job.stream} is not in the discover result`, logs.drain());
|
|
58
|
+
return runPull(definition, { ...job, tables: Object.keys(stream.tables) }, options);
|
|
59
|
+
}
|
|
60
|
+
async function execute(json, factory) {
|
|
61
|
+
const { expect, job } = jsonParse(json);
|
|
62
|
+
const loaded = loadDefinition(factory, expect);
|
|
63
|
+
if (!loaded.ok)
|
|
64
|
+
return failure('CONNECTOR_LOAD_FAILED', loaded.message, [], connectorStack(loaded.stack));
|
|
65
|
+
return run(loaded.definition, job);
|
|
66
|
+
}
|
|
67
|
+
let started = false;
|
|
68
|
+
function start(json, factory) {
|
|
69
|
+
if (started || typeof json !== 'string')
|
|
70
|
+
return;
|
|
71
|
+
started = true;
|
|
72
|
+
let pending;
|
|
73
|
+
try {
|
|
74
|
+
pending = execute(json, factory);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
finish(failure('FATAL_ERROR', errorInfo(error).message, []));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
pending.then((outcome) => finish(outcome), (error) => finish(failure('FATAL_ERROR', `connector runtime failed: ${errorInfo(error).message}`, [])));
|
|
81
|
+
}
|
|
82
|
+
export function bootGuest() {
|
|
83
|
+
for (const name of STRIPPED_GLOBALS)
|
|
84
|
+
Reflect.deleteProperty(globalThis, name);
|
|
85
|
+
return Object.freeze({ start, handle, poll });
|
|
86
|
+
}
|
|
87
|
+
export function bootLocalGuest() {
|
|
88
|
+
return Object.freeze({ start, handle, poll });
|
|
89
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** guest 侧限额;与 shared 的 connector-limits 同值(SDK 不依赖 shared)。 */
|
|
2
|
+
export declare const PULL_BATCH_MAX_RECORDS = 1000;
|
|
3
|
+
export declare const PULL_BATCH_MAX_BYTES: number;
|
|
4
|
+
/** 一次拉取运行里被拒收的记录超过这个数时运行失败(与 shared 的 `PULL_REJECT_THRESHOLD` 同值)。 */
|
|
5
|
+
export declare const PULL_REJECT_THRESHOLD = 1000;
|
|
6
|
+
export declare const MAX_LOGS = 1000;
|
|
7
|
+
export declare const MAX_LOG_MESSAGE = 4096;
|
|
8
|
+
/** 连接器与连接都没声明限流时网关用的并发(`EGRESS_DEFAULT_RATE.concurrency`)。 */
|
|
9
|
+
export declare const DEFAULT_CONCURRENCY = 4;
|
|
10
|
+
/** 429 / 网关限流的最多重试次数。 */
|
|
11
|
+
export declare const RATE_LIMIT_RETRIES = 5;
|
|
12
|
+
/** 上游 502 / 503 / 504 与网关超时的最多重试次数,退避从 200 ms 起、每次翻倍。 */
|
|
13
|
+
export declare const TRANSIENT_RETRIES = 3;
|
|
14
|
+
export declare const TRANSIENT_BACKOFF_MS = 200;
|
|
15
|
+
/** 429 没给 `Retry-After` 时的等待起点(每次翻倍)。 */
|
|
16
|
+
export declare const RATE_LIMIT_BACKOFF_MS = 1000;
|
|
17
|
+
/** 单次等待上限,避免来源给出离谱的 `Retry-After`。 */
|
|
18
|
+
export declare const MAX_RETRY_WAIT_MS = 600000;
|
|
19
|
+
/** 连接器代码在 vm 里的文件名;失败回报的调用栈只保留这个文件的帧。 */
|
|
20
|
+
export declare const CONNECTOR_FILENAME = "connector.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const PULL_BATCH_MAX_RECORDS = 1000;
|
|
2
|
+
export const PULL_BATCH_MAX_BYTES = 4 * 1024 * 1024;
|
|
3
|
+
export const PULL_REJECT_THRESHOLD = 1000;
|
|
4
|
+
export const MAX_LOGS = 1000;
|
|
5
|
+
export const MAX_LOG_MESSAGE = 4096;
|
|
6
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
export const RATE_LIMIT_RETRIES = 5;
|
|
8
|
+
export const TRANSIENT_RETRIES = 3;
|
|
9
|
+
export const TRANSIENT_BACKOFF_MS = 200;
|
|
10
|
+
export const RATE_LIMIT_BACKOFF_MS = 1000;
|
|
11
|
+
export const MAX_RETRY_WAIT_MS = 600_000;
|
|
12
|
+
export const CONNECTOR_FILENAME = 'connector.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ConnectorDefinition } from '../../types.js';
|
|
2
|
+
export declare const SDK_SPECIFIER = "@deepexi/datasense-connector";
|
|
3
|
+
export type LoadOutcome = {
|
|
4
|
+
ok: true;
|
|
5
|
+
definition: ConnectorDefinition;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
message: string;
|
|
9
|
+
stack: string | undefined;
|
|
10
|
+
};
|
|
11
|
+
/** 执行连接器代码、取默认导出、校验定义并核对期望的名字与版本。 */
|
|
12
|
+
export declare function loadDefinition(factory: unknown, expect: {
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
}): LoadOutcome;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ConnectorError } from '../../errors.js';
|
|
2
|
+
import * as sdk from '../../index.js';
|
|
3
|
+
import { validateConnectorDefinition } from '../../validate.js';
|
|
4
|
+
import { errorInfo } from './failure.js';
|
|
5
|
+
export const SDK_SPECIFIER = '@deepexi/datasense-connector';
|
|
6
|
+
const sdkModule = Object.freeze({ ...sdk, __esModule: true });
|
|
7
|
+
function connectorRequire(specifier) {
|
|
8
|
+
if (specifier === SDK_SPECIFIER)
|
|
9
|
+
return sdkModule;
|
|
10
|
+
throw new ConnectorError('CONNECTOR_LOAD_FAILED', `cannot require ${String(specifier)}: connectors can only import ${SDK_SPECIFIER} and relative files`);
|
|
11
|
+
}
|
|
12
|
+
function resolveExport(exported) {
|
|
13
|
+
if (exported !== null && (typeof exported === 'object' || typeof exported === 'function')) {
|
|
14
|
+
const fallback = exported.default;
|
|
15
|
+
if (fallback !== undefined && fallback !== null)
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
return exported;
|
|
19
|
+
}
|
|
20
|
+
export function loadDefinition(factory, expect) {
|
|
21
|
+
if (typeof factory !== 'function')
|
|
22
|
+
return { ok: false, message: 'connector code did not compile to a module', stack: undefined };
|
|
23
|
+
let definition;
|
|
24
|
+
try {
|
|
25
|
+
const module = { exports: {} };
|
|
26
|
+
Reflect.apply(factory, undefined, [module, module.exports, connectorRequire]);
|
|
27
|
+
definition = resolveExport(module.exports);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const info = errorInfo(error);
|
|
31
|
+
return { ok: false, message: `connector code failed to load: ${info.message}`, stack: info.stack };
|
|
32
|
+
}
|
|
33
|
+
const issues = validateConnectorDefinition(definition);
|
|
34
|
+
if (issues.length > 0) {
|
|
35
|
+
return { ok: false, message: `invalid connector definition: ${issues.map((issue) => `${issue.path || '(root)'}: ${issue.message}`).join('; ')}`, stack: undefined };
|
|
36
|
+
}
|
|
37
|
+
const loaded = definition;
|
|
38
|
+
if (loaded.name !== expect.name || loaded.version !== expect.version) {
|
|
39
|
+
return { ok: false, message: `connector code defines ${loaded.name}@${loaded.version}, expected ${expect.name}@${expect.version}`, stack: undefined };
|
|
40
|
+
}
|
|
41
|
+
return { ok: true, definition: loaded };
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `ctx.log`:一次运行最多保留 1000 条,单条超过 4096 字符截断;拉取作业每批 `emit` 带走已有的日志。 */
|
|
2
|
+
import type { ConnectorLog } from '../../types.js';
|
|
3
|
+
import type { GuestLog } from '../protocol.js';
|
|
4
|
+
export interface LogBuffer {
|
|
5
|
+
/** 取走尚未上报的日志。 */
|
|
6
|
+
drain(): GuestLog[];
|
|
7
|
+
/** `isOpen` 返回 false 时丢弃(推送请求结束后再写日志无效)。 */
|
|
8
|
+
logger(requestId?: string, isOpen?: () => boolean): ConnectorLog;
|
|
9
|
+
}
|
|
10
|
+
export declare function createLogBuffer(): LogBuffer;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsonStringify } from './bridge.js';
|
|
2
|
+
import { MAX_LOG_MESSAGE, MAX_LOGS } from './limits.js';
|
|
3
|
+
import { isoNow } from './time.js';
|
|
4
|
+
function render(message, data) {
|
|
5
|
+
let text = typeof message === 'string' ? message : 'non-string log message';
|
|
6
|
+
if (data !== undefined) {
|
|
7
|
+
try {
|
|
8
|
+
const extra = jsonStringify(data);
|
|
9
|
+
if (typeof extra === 'string')
|
|
10
|
+
text = `${text} ${extra}`;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return text.length > MAX_LOG_MESSAGE ? text.slice(0, MAX_LOG_MESSAGE) : text;
|
|
16
|
+
}
|
|
17
|
+
export function createLogBuffer() {
|
|
18
|
+
let entries = [];
|
|
19
|
+
let total = 0;
|
|
20
|
+
const write = (level, requestId, isOpen) => (message, data) => {
|
|
21
|
+
if (total >= MAX_LOGS || (isOpen && !isOpen()))
|
|
22
|
+
return;
|
|
23
|
+
total += 1;
|
|
24
|
+
entries[entries.length] = { at: isoNow(), level, message: render(message, data), ...(requestId !== undefined ? { requestId } : {}) };
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
drain() {
|
|
28
|
+
const out = entries;
|
|
29
|
+
entries = [];
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
logger(requestId, isOpen) {
|
|
33
|
+
return Object.freeze({ info: write('info', requestId, isOpen), warn: write('warn', requestId, isOpen), error: write('error', requestId, isOpen) });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { throwIfAborted } from './bridge.js';
|
|
2
|
+
import { DEFAULT_CONCURRENCY } from './limits.js';
|
|
3
|
+
function positiveInteger(value, what) {
|
|
4
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1)
|
|
5
|
+
throw new TypeError(`${what} must be a positive integer`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
export function createMap(concurrency) {
|
|
9
|
+
const cap = concurrency ?? DEFAULT_CONCURRENCY;
|
|
10
|
+
return async (items, fn, options) => {
|
|
11
|
+
if (!Array.isArray(items))
|
|
12
|
+
throw new TypeError('ctx.map expects an array');
|
|
13
|
+
if (typeof fn !== 'function')
|
|
14
|
+
throw new TypeError('ctx.map expects a function');
|
|
15
|
+
const requested = options?.concurrency === undefined ? cap : positiveInteger(options.concurrency, 'ctx.map concurrency');
|
|
16
|
+
const limit = Math.min(requested, cap);
|
|
17
|
+
const results = new Array(items.length);
|
|
18
|
+
let next = 0;
|
|
19
|
+
let failed = false;
|
|
20
|
+
let firstError;
|
|
21
|
+
const worker = async () => {
|
|
22
|
+
while (!failed && next < items.length) {
|
|
23
|
+
const index = next;
|
|
24
|
+
next += 1;
|
|
25
|
+
try {
|
|
26
|
+
throwIfAborted();
|
|
27
|
+
results[index] = await fn(items[index]);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (!failed) {
|
|
31
|
+
failed = true;
|
|
32
|
+
firstError = error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const workers = [];
|
|
38
|
+
for (let i = 0; i < Math.min(limit, items.length); i += 1)
|
|
39
|
+
workers.push(worker());
|
|
40
|
+
await Promise.all(workers);
|
|
41
|
+
if (failed)
|
|
42
|
+
throw firstError;
|
|
43
|
+
return results;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** `ctx.paginate`:四种 HTTP 分页与数据库按键分页,都是产出 `Page` 的异步迭代器;取到空页即结束(空页不产出)。 */
|
|
2
|
+
import type { HttpClient, Page, Paginate } from '../../types.js';
|
|
3
|
+
export declare function makePage<Row>(rows: Row[]): Page<Row>;
|
|
4
|
+
/** 解析 `Link` 头里 `rel="next"` 的地址。 */
|
|
5
|
+
export declare function nextLink(header: string | undefined): string | null;
|
|
6
|
+
export declare function createPaginate(http: HttpClient): Paginate;
|