@deepexi/datasense-connector 3.0.1 → 3.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/README.md +158 -139
- package/package.json +19 -28
- package/scripts/build.ts +188 -0
- package/src/define.ts +267 -0
- package/src/descriptor.ts +116 -0
- package/src/errors.ts +105 -0
- package/src/index.ts +108 -0
- package/src/internal/location.ts +23 -0
- package/src/runtime/guest/bridge.ts +238 -0
- package/src/runtime/guest/cache.ts +43 -0
- package/src/runtime/guest/check.ts +17 -0
- package/src/runtime/guest/crypto.ts +166 -0
- package/src/runtime/guest/ctx.ts +157 -0
- package/src/runtime/guest/db.ts +59 -0
- package/src/runtime/guest/failure.ts +69 -0
- package/src/runtime/guest/http.ts +194 -0
- package/src/runtime/guest/index.ts +102 -0
- package/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
- package/src/runtime/guest/loader.ts +210 -0
- package/src/runtime/guest/log.ts +47 -0
- package/src/runtime/guest/map.ts +45 -0
- package/src/runtime/guest/operation.ts +193 -0
- package/src/runtime/guest/paginate.ts +169 -0
- package/src/runtime/guest/pull.ts +177 -0
- package/src/runtime/guest/push.ts +92 -0
- package/src/runtime/guest/signal.ts +14 -0
- package/src/runtime/guest/suggest.ts +51 -0
- package/src/runtime/guest/time.ts +137 -0
- package/src/runtime/guest-runtime.ts +75 -0
- package/src/runtime/index.ts +53 -0
- package/src/runtime/protocol.ts +557 -0
- package/src/runtime/session.ts +216 -0
- package/src/testing/child.ts +21 -0
- package/src/testing/compliance.ts +343 -0
- package/src/testing/engine.ts +445 -0
- package/src/testing/fixtures.ts +591 -0
- package/src/testing/gateway.ts +482 -0
- package/src/testing/import-guard.ts +221 -0
- package/src/testing/index.ts +43 -0
- package/src/testing/local-db.ts +226 -0
- package/src/testing/mock-external-system.ts +249 -0
- package/src/testing/pinned-http.ts +144 -0
- package/src/testing/recorder.ts +140 -0
- package/src/testing/redaction.ts +94 -0
- package/src/testing/replay.ts +437 -0
- package/src/testing/run-tests.ts +115 -0
- package/src/testing/schema.ts +70 -0
- package/src/testing/source-loader.ts +374 -0
- package/src/testing/types.ts +78 -0
- package/src/types.ts +329 -0
- package/src/validate.ts +267 -0
- package/test/build.test.ts +159 -0
- package/test/define.test-d.ts +182 -0
- package/test/fixtures/acceptance.ts +156 -0
- package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
- package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
- package/test/fixtures/connectors/http-pull/package.json +5 -0
- package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
- package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
- package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
- package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
- package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
- package/test/fixtures/connectors/pg-pull/package.json +5 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
- package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
- package/test/fixtures/descriptor.ts +54 -0
- package/test/fixtures/kingdee-example.ts +82 -0
- package/test/fixtures/record-acceptance.ts +32 -0
- package/test/runtime/ctx.test.ts +434 -0
- package/test/runtime/helpers.ts +157 -0
- package/test/runtime/jobs.test.ts +444 -0
- package/test/runtime/operation.test.ts +175 -0
- package/test/runtime/session.test.ts +229 -0
- package/test/testing/acceptance-fixtures.test.ts +69 -0
- package/test/testing/compliance.test.ts +247 -0
- package/test/testing/engine.test.ts +283 -0
- package/test/testing/fixtures.test.ts +221 -0
- package/test/testing/gateway.test.ts +337 -0
- package/test/testing/import-guard.test.ts +45 -0
- package/test/testing/mock-external-system.test.ts +144 -0
- package/test/testing/operation-fixtures.test.ts +160 -0
- package/test/testing/recorder.test.ts +163 -0
- package/test/testing/redaction.test.ts +54 -0
- package/test/testing/replay.test.ts +207 -0
- package/test/testing/run-tests.test.ts +137 -0
- package/test/testing/source-loader.test.ts +220 -0
- package/test/validate-config.test.ts +104 -0
- package/tsconfig.build.json +23 -0
- package/tsconfig.json +19 -0
- package/cjs/index.cjs +0 -1015
- package/cjs/runtime.cjs +0 -752
- package/cjs/testing.cjs +0 -3591
- package/errors.d.ts +0 -17
- package/errors.js +0 -53
- package/guest.cjs +0 -2659
- package/index.d.ts +0 -13
- package/index.js +0 -7
- package/internal/location.d.ts +0 -6
- package/internal/location.js +0 -8
- package/presentation.d.ts +0 -38
- package/presentation.js +0 -268
- package/runtime/guest/bridge.d.ts +0 -53
- package/runtime/guest/bridge.js +0 -187
- package/runtime/guest/cache.d.ts +0 -3
- package/runtime/guest/cache.js +0 -42
- package/runtime/guest/crypto.d.ts +0 -13
- package/runtime/guest/crypto.js +0 -177
- package/runtime/guest/ctx.d.ts +0 -30
- package/runtime/guest/ctx.js +0 -124
- package/runtime/guest/db.d.ts +0 -6
- package/runtime/guest/db.js +0 -59
- package/runtime/guest/discover.d.ts +0 -29
- package/runtime/guest/discover.js +0 -51
- package/runtime/guest/failure.d.ts +0 -24
- package/runtime/guest/failure.js +0 -63
- package/runtime/guest/http.d.ts +0 -4
- package/runtime/guest/http.js +0 -204
- package/runtime/guest/index.d.ts +0 -12
- package/runtime/guest/index.js +0 -89
- package/runtime/guest/limits.js +0 -12
- package/runtime/guest/loader.d.ts +0 -15
- package/runtime/guest/loader.js +0 -42
- package/runtime/guest/log.d.ts +0 -10
- package/runtime/guest/log.js +0 -36
- package/runtime/guest/map.d.ts +0 -4
- package/runtime/guest/map.js +0 -45
- package/runtime/guest/paginate.d.ts +0 -6
- package/runtime/guest/paginate.js +0 -180
- package/runtime/guest/pull.d.ts +0 -12
- package/runtime/guest/pull.js +0 -150
- package/runtime/guest/push.d.ts +0 -18
- package/runtime/guest/push.js +0 -80
- package/runtime/guest/signal.d.ts +0 -3
- package/runtime/guest/signal.js +0 -12
- package/runtime/guest/time.d.ts +0 -5
- package/runtime/guest/time.js +0 -127
- package/runtime/guest-runtime.d.ts +0 -7
- package/runtime/guest-runtime.js +0 -51
- package/runtime/index.d.ts +0 -18
- package/runtime/index.js +0 -3
- package/runtime/protocol.d.ts +0 -276
- package/runtime/protocol.js +0 -409
- package/runtime/session.d.ts +0 -12
- package/runtime/session.js +0 -191
- package/t.d.ts +0 -18
- package/t.js +0 -26
- package/testing/child.cjs +0 -350
- package/testing/child.d.ts +0 -1
- package/testing/child.js +0 -12
- package/testing/compliance.d.ts +0 -41
- package/testing/compliance.js +0 -291
- package/testing/engine.d.ts +0 -66
- package/testing/engine.js +0 -339
- package/testing/fixtures.d.ts +0 -145
- package/testing/fixtures.js +0 -407
- package/testing/gateway.d.ts +0 -65
- package/testing/gateway.js +0 -402
- package/testing/import-guard.d.ts +0 -57
- package/testing/import-guard.js +0 -187
- package/testing/index.d.ts +0 -33
- package/testing/index.js +0 -10
- package/testing/local-db.d.ts +0 -27
- package/testing/local-db.js +0 -181
- package/testing/pinned-http.d.ts +0 -14
- package/testing/pinned-http.js +0 -125
- package/testing/recorder.d.ts +0 -15
- package/testing/recorder.js +0 -116
- package/testing/redaction.d.ts +0 -28
- package/testing/redaction.js +0 -67
- package/testing/replay.d.ts +0 -50
- package/testing/replay.js +0 -313
- package/testing/schema.d.ts +0 -13
- package/testing/schema.js +0 -65
- package/testing/source-loader.d.ts +0 -19
- package/testing/source-loader.js +0 -238
- package/testing/types.d.ts +0 -79
- package/testing/types.js +0 -18
- package/types.d.ts +0 -302
- package/types.js +0 -13
- package/validate.d.ts +0 -78
- package/validate.js +0 -597
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地执行器:同一个 guest 作业交给两种执行方式,宿主调用一律交给传入的 `LocalHost`(本地模拟网关或夹具回放)。
|
|
3
|
+
*
|
|
4
|
+
* - `createVmExecutor(source)`:在空环境变量的子进程里执行(Node 用 `child_process.fork`,Bun 下同样 fork 当前运行时),
|
|
5
|
+
* 子进程只建 `createGuestSession`(与 runner 同一个 guest 会话、同一个 vm 上下文),宿主调用经 IPC 回到父进程;
|
|
6
|
+
* 超时或取消时杀子进程(ADR-0133 决策 8)。给 CLI 的 `read` / `check` / `test` 与 `runTests`。
|
|
7
|
+
* - `createInProcessExecutor(module)`:在当前进程里执行已导入的连接器模块(`{ descriptor, implementations }`,与打包产物同形),
|
|
8
|
+
* 复用同一份 guest 运行时(`ctx` 实现、批次、失败归类都与 vm 内一致),只是不过 vm。给 vitest。
|
|
9
|
+
*
|
|
10
|
+
* 两种方式共用同一个消息驱动(`drive`):父进程侧按 `parseChildMessage` 校验每条消息,`emit` 累积记录与断点并实现 `limit`
|
|
11
|
+
* (累计记录数达到 limit 时截掉超出部分、回 `{ stop: true }`),其余方法交给 `host.call`。
|
|
12
|
+
*/
|
|
13
|
+
import { fork, type ChildProcess } from 'node:child_process';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { IS_SOURCE, PACKAGE_ROOT } from '../internal/location';
|
|
16
|
+
import { loadGuestRuntime } from '../runtime/guest-runtime';
|
|
17
|
+
import {
|
|
18
|
+
parseChildMessage, undeclaredEmitTable, type ChildMessage, type GuestFailureCode, type GuestJob, type GuestLog, type GuestOperationPhase,
|
|
19
|
+
type GuestOperationResult, type GuestResult, type HostCallError, type ParentMessage,
|
|
20
|
+
} from '../runtime/protocol';
|
|
21
|
+
import { createInProcessGuestSession } from '../runtime/session';
|
|
22
|
+
import type { ConnectorDescriptor } from '../descriptor';
|
|
23
|
+
import type { LocalConnector } from './source-loader';
|
|
24
|
+
import { LocalRunError, type GuestExecutor, type LocalHost, type LocalRunResult } from './types';
|
|
25
|
+
|
|
26
|
+
/** 执行器的传输层:把父进程消息交给 guest,guest 的消息经 `onMessage` 回来。 */
|
|
27
|
+
interface Transport {
|
|
28
|
+
send(message: ParentMessage): void;
|
|
29
|
+
/** 结束执行(杀子进程 / 丢弃进程内会话),等它真正退出。 */
|
|
30
|
+
stop(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TransportFactory = (onMessage: (raw: unknown) => void, onExit: (description: string) => void) => Transport;
|
|
34
|
+
|
|
35
|
+
interface DriveInput {
|
|
36
|
+
execute: Extract<ParentMessage, { type: 'execute' }>;
|
|
37
|
+
host: LocalHost;
|
|
38
|
+
limit?: number;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** vm 子进程先发 `ready` 再收 `execute`;进程内会话直接收 `execute`。 */
|
|
41
|
+
waitForReady: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorMessage(error: unknown): string {
|
|
49
|
+
return error instanceof Error ? error.message : String(error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function emptyResult(): LocalRunResult {
|
|
53
|
+
return { records: [], removals: [], checkpoints: [], logs: [], rejected: [], stopped: false, error: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 宿主抛出的错误 → 回给 guest 的宿主调用失败。本地网关的 `HOST_DENIED` 回线上同码 `EGRESS_HOST_DENIED`。 */
|
|
57
|
+
function hostCallError(error: unknown): HostCallError {
|
|
58
|
+
const code = isRecord(error) && typeof error.code === 'string' ? error.code : 'HOST_CALL_FAILED';
|
|
59
|
+
const result: HostCallError = { code: code === 'HOST_DENIED' ? 'EGRESS_HOST_DENIED' : code, message: errorMessage(error) };
|
|
60
|
+
if (isRecord(error) && typeof error.status === 'number' && Number.isInteger(error.status)) result.status = error.status;
|
|
61
|
+
if (isRecord(error) && typeof error.retryAfterMs === 'number' && error.retryAfterMs >= 0) result.retryAfterMs = error.retryAfterMs;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decode(raw: unknown): unknown {
|
|
66
|
+
if (typeof raw !== 'string') return raw;
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 操作结果里的文案与日志过一遍遮蔽(输出是外部系统的业务数据,原样保留)。 */
|
|
75
|
+
function redactOperation(outcome: GuestOperationResult, redact: (text: string) => string): GuestOperationResult {
|
|
76
|
+
const logs = outcome.logs.map((log) => ({ ...log, message: redact(log.message) }));
|
|
77
|
+
if (outcome.ok) return { ...outcome, logs };
|
|
78
|
+
return {
|
|
79
|
+
...outcome,
|
|
80
|
+
message: redact(outcome.message),
|
|
81
|
+
...(outcome.external ? { external: { ...outcome.external, message: redact(outcome.external.message) } } : {}),
|
|
82
|
+
logs,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 一次作业的消息驱动(两种执行器共用)。 */
|
|
87
|
+
function drive(transportFactory: TransportFactory, input: DriveInput): Promise<LocalRunResult> {
|
|
88
|
+
const { execute, host } = input;
|
|
89
|
+
const job = execute.job;
|
|
90
|
+
const redact = (text: string): string => (host.redact ? host.redact(text) : text);
|
|
91
|
+
const result = emptyResult();
|
|
92
|
+
const limit = input.limit !== undefined && Number.isFinite(input.limit) && input.limit >= 0 ? Math.floor(input.limit) : null;
|
|
93
|
+
let seq = 0;
|
|
94
|
+
let finished = false;
|
|
95
|
+
|
|
96
|
+
const addLogs = (logs: readonly GuestLog[]): void => {
|
|
97
|
+
for (const log of logs) result.logs.push({ at: log.at, level: log.level, message: redact(log.message) });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return new Promise<LocalRunResult>((resolve) => {
|
|
101
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
102
|
+
let transport: Transport | null = null;
|
|
103
|
+
|
|
104
|
+
const finish = (error: LocalRunResult['error']): void => {
|
|
105
|
+
if (finished) return;
|
|
106
|
+
finished = true;
|
|
107
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
108
|
+
if (error) result.error = { code: error.code, message: redact(error.message), stack: error.stack === null ? null : redact(error.stack) };
|
|
109
|
+
const stopping = transport ? transport.stop() : Promise.resolve();
|
|
110
|
+
void stopping.then(() => resolve(result), () => resolve(result));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const send = (message: ParentMessage): void => {
|
|
114
|
+
if (finished || !transport) return;
|
|
115
|
+
transport.send(message);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const onEmit = (id: number, payload: Extract<ChildMessage, { type: 'call' }>['payload']): void => {
|
|
119
|
+
const batch = payload as { records: LocalRunResult['records']; removals: LocalRunResult['removals']; checkpoint?: { state: unknown }; logs: GuestLog[] };
|
|
120
|
+
seq += 1;
|
|
121
|
+
addLogs(batch.logs);
|
|
122
|
+
let records = batch.records;
|
|
123
|
+
let stop = false;
|
|
124
|
+
if (limit !== null) {
|
|
125
|
+
const room = Math.max(0, limit - result.records.length);
|
|
126
|
+
if (records.length >= room) {
|
|
127
|
+
stop = true;
|
|
128
|
+
records = records.slice(0, room);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const truncated = records.length < batch.records.length;
|
|
132
|
+
result.records.push(...records);
|
|
133
|
+
result.removals.push(...batch.removals);
|
|
134
|
+
// 截掉了记录时,这一批的断点跨过了被截掉的记录,不记。
|
|
135
|
+
if (batch.checkpoint && !truncated) result.checkpoints.push({ seq, state: batch.checkpoint.state });
|
|
136
|
+
if (stop) result.stopped = true;
|
|
137
|
+
send({ type: 'reply', id, ok: true, value: { seq, duplicate: false, rejected: 0, ...(stop ? { stop: true } : {}) } });
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const onCall = (message: Extract<ChildMessage, { type: 'call' }>): void => {
|
|
141
|
+
switch (message.method) {
|
|
142
|
+
case 'emit':
|
|
143
|
+
onEmit(message.id, message.payload);
|
|
144
|
+
return;
|
|
145
|
+
default:
|
|
146
|
+
host.call(message.method, message.payload).then(
|
|
147
|
+
(value) => send({ type: 'reply', id: message.id, ok: true, value: value ?? null }),
|
|
148
|
+
(error: unknown) => {
|
|
149
|
+
if (error instanceof LocalRunError && error.code === 'DB_DRIVER_MISSING') {
|
|
150
|
+
finish({ code: error.code, message: error.message, stack: null });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
send({ type: 'reply', id: message.id, ok: false, error: hostCallError(error) });
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const onResult = (outcome: GuestResult): void => {
|
|
160
|
+
switch (outcome.kind) {
|
|
161
|
+
case 'push':
|
|
162
|
+
for (const record of outcome.records) result.records.push({ table: record.table, row: record.row });
|
|
163
|
+
for (const removal of outcome.removals) result.removals.push({ table: removal.table, key: removal.key });
|
|
164
|
+
result.rejected.push(...outcome.rejected.map((item) => ({ requestId: item.requestId, code: item.code, message: redact(item.message) })));
|
|
165
|
+
break;
|
|
166
|
+
case 'check':
|
|
167
|
+
break;
|
|
168
|
+
case 'suggest':
|
|
169
|
+
result.suggest = outcome.columns.map((column) => ({ ...column }));
|
|
170
|
+
break;
|
|
171
|
+
case 'pull':
|
|
172
|
+
if (outcome.stopped === true) result.stopped = true;
|
|
173
|
+
break;
|
|
174
|
+
case 'operation':
|
|
175
|
+
result.operation = redactOperation(outcome, redact);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
addLogs(outcome.logs);
|
|
179
|
+
finish(null);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const onMessage = (raw: unknown): void => {
|
|
183
|
+
if (finished) return;
|
|
184
|
+
const decoded = decode(raw);
|
|
185
|
+
const message = parseChildMessage(decoded, job);
|
|
186
|
+
if (!message) {
|
|
187
|
+
// 与 runner 一致:写了没声明的表按 SCHEMA_ERROR,其余按崩溃。
|
|
188
|
+
const table = undeclaredEmitTable(decoded, job);
|
|
189
|
+
if (table !== null) {
|
|
190
|
+
finish({ code: 'SCHEMA_ERROR', message: `connector emitted records for undeclared table ${table}`, stack: null });
|
|
191
|
+
} else {
|
|
192
|
+
finish({ code: 'RUNNER_CRASHED', message: 'connector sandbox sent a malformed message', stack: null });
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
switch (message.type) {
|
|
197
|
+
case 'ready':
|
|
198
|
+
send(execute);
|
|
199
|
+
return;
|
|
200
|
+
case 'call':
|
|
201
|
+
onCall(message);
|
|
202
|
+
return;
|
|
203
|
+
case 'result':
|
|
204
|
+
onResult(message.result);
|
|
205
|
+
return;
|
|
206
|
+
case 'failure':
|
|
207
|
+
addLogs(message.logs);
|
|
208
|
+
finish({ code: message.code, message: message.message, stack: message.stack ?? null });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const onExit = (description: string): void => {
|
|
214
|
+
finish({ code: 'RUNNER_CRASHED', message: `connector sandbox exited without a result (${description})`, stack: null });
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
transport = transportFactory(onMessage, onExit);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
finish({ code: 'RUNNER_CRASHED', message: `connector sandbox failed to start: ${errorMessage(error)}`, stack: null });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (input.timeoutMs !== undefined && Number.isFinite(input.timeoutMs)) {
|
|
224
|
+
timer = setTimeout(() => {
|
|
225
|
+
finish({ code: 'TIMEOUT', message: `connector execution exceeded ${input.timeoutMs} ms`, stack: null });
|
|
226
|
+
}, Math.max(0, input.timeoutMs));
|
|
227
|
+
}
|
|
228
|
+
if (!input.waitForReady) send(execute);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// 进程内
|
|
234
|
+
|
|
235
|
+
/** 连接器模块里的描述(结构不对时为 undefined,由 guest 加载时报错)。 */
|
|
236
|
+
function descriptorOf(module: unknown): ConnectorDescriptor | undefined {
|
|
237
|
+
if (!isRecord(module)) return undefined;
|
|
238
|
+
const descriptor = module.descriptor;
|
|
239
|
+
return isRecord(descriptor) && typeof descriptor.name === 'string' && typeof descriptor.version === 'string' ? descriptor as unknown as ConnectorDescriptor : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** vitest:当前进程直接执行已导入的连接器模块(`{ descriptor, implementations }`),不过 vm(复用 guest 运行时的 `ctx` 实现)。 */
|
|
243
|
+
export function createInProcessExecutor(module: unknown): GuestExecutor {
|
|
244
|
+
const descriptor = descriptorOf(module);
|
|
245
|
+
return {
|
|
246
|
+
...(descriptor ? { descriptor } : {}),
|
|
247
|
+
async run(job, host, options = {}) {
|
|
248
|
+
const guest = await loadGuestRuntime();
|
|
249
|
+
const expect = { name: descriptor?.name ?? '', version: descriptor?.version ?? '' };
|
|
250
|
+
const execute: Extract<ParentMessage, { type: 'execute' }> = { type: 'execute', guest, code: '', expect, job };
|
|
251
|
+
return drive((onMessage) => {
|
|
252
|
+
let active = true;
|
|
253
|
+
const session = createInProcessGuestSession((json) => {
|
|
254
|
+
// guest 的消息在会话的定时器回调里发出;交给驱动前切到下一个微任务,避免在 guest 调用栈里重入。
|
|
255
|
+
if (active) queueMicrotask(() => onMessage(json));
|
|
256
|
+
}, module);
|
|
257
|
+
return {
|
|
258
|
+
send(message) {
|
|
259
|
+
if (active) session.handle(JSON.stringify(message));
|
|
260
|
+
},
|
|
261
|
+
async stop() {
|
|
262
|
+
if (!active) return;
|
|
263
|
+
// 进程内无法强行打断连接器代码:发 cancel 让 guest 尽快停下,之后的消息一律丢弃。
|
|
264
|
+
session.handle(JSON.stringify({ type: 'cancel' }));
|
|
265
|
+
active = false;
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}, { execute, host, limit: options.limit, timeoutMs: options.timeoutMs, waitForReady: false });
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// vm 子进程
|
|
275
|
+
|
|
276
|
+
/** 子进程入口:源码下是 `src/testing/child.ts`(Bun 直接执行),dist 下是构建产出的 `testing/child.cjs`。 */
|
|
277
|
+
export const VM_CHILD_ENTRY: string = path.join(PACKAGE_ROOT, 'testing', IS_SOURCE ? 'child.ts' : 'child.cjs');
|
|
278
|
+
|
|
279
|
+
export interface VmExecutorOptions {
|
|
280
|
+
/** guest 运行时源码;缺省 `loadGuestRuntime()`。CLI 传构建时内联的 `guest.cjs` 文本。 */
|
|
281
|
+
guestRuntime?: string;
|
|
282
|
+
/** 子进程入口;缺省 `VM_CHILD_ENTRY`(CLI 打成单文件时传自己发布的路径,测试用来换替身)。 */
|
|
283
|
+
childEntry?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** `cancel` / 超时后等子进程退出的上限;到时 SIGKILL。 */
|
|
287
|
+
const KILL_WAIT_MS = 5000;
|
|
288
|
+
|
|
289
|
+
function forkTransport(entry: string): TransportFactory {
|
|
290
|
+
return (onMessage, onExit) => {
|
|
291
|
+
const child: ChildProcess = fork(entry, [], {
|
|
292
|
+
env: {},
|
|
293
|
+
execArgv: [],
|
|
294
|
+
// 子进程的 stdout 转到父进程 stderr:连接器或它的依赖往 stdout 写的东西不能混进 CLI 的 NDJSON / JSON 输出。
|
|
295
|
+
stdio: ['ignore', 'pipe', 'inherit', 'ipc'],
|
|
296
|
+
serialization: 'json',
|
|
297
|
+
});
|
|
298
|
+
child.stdout?.on('data', (chunk: Buffer) => process.stderr.write(chunk));
|
|
299
|
+
let exited = false;
|
|
300
|
+
const exitedPromise = new Promise<void>((resolve) => {
|
|
301
|
+
child.once('exit', (code, signal) => {
|
|
302
|
+
exited = true;
|
|
303
|
+
resolve();
|
|
304
|
+
onExit(signal ? `signal ${signal}` : `exit code ${String(code)}`);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
child.on('message', (raw: unknown) => onMessage(raw));
|
|
308
|
+
child.on('error', (error) => onExit(`spawn error: ${error.message}`));
|
|
309
|
+
return {
|
|
310
|
+
send(message) {
|
|
311
|
+
if (exited || !child.connected) return;
|
|
312
|
+
try {
|
|
313
|
+
child.send(JSON.stringify(message));
|
|
314
|
+
} catch {
|
|
315
|
+
// 子进程已退出:由 exit 处理
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
async stop() {
|
|
319
|
+
if (!exited) child.kill('SIGKILL');
|
|
320
|
+
await Promise.race([exitedPromise, new Promise<void>((resolve) => setTimeout(resolve, KILL_WAIT_MS))]);
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** vm 内执行(CLI 与 `runTests` 用):空环境变量的子进程 + 与 runner 同一个 guest 会话。 */
|
|
327
|
+
export function createVmExecutor(connector: LocalConnector, options: VmExecutorOptions = {}): GuestExecutor {
|
|
328
|
+
const entry = options.childEntry ?? VM_CHILD_ENTRY;
|
|
329
|
+
return {
|
|
330
|
+
descriptor: connector.descriptor,
|
|
331
|
+
async run(job, host, runOptions = {}) {
|
|
332
|
+
const guest = options.guestRuntime ?? await loadGuestRuntime();
|
|
333
|
+
const execute: Extract<ParentMessage, { type: 'execute' }> = {
|
|
334
|
+
type: 'execute', guest, code: connector.code, expect: { name: connector.name, version: connector.version }, job,
|
|
335
|
+
};
|
|
336
|
+
return drive(forkTransport(entry), { execute, host, limit: runOptions.limit, timeoutMs: runOptions.timeoutMs, waitForReady: true });
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// 进程内便捷包装(vitest 用)
|
|
343
|
+
|
|
344
|
+
export interface StreamRunInput {
|
|
345
|
+
stream: string;
|
|
346
|
+
mode?: 'full' | 'incremental';
|
|
347
|
+
state?: unknown;
|
|
348
|
+
streamConfig?: Record<string, unknown>;
|
|
349
|
+
config: Record<string, unknown>;
|
|
350
|
+
limit?: number;
|
|
351
|
+
host: LocalHost;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 作业的 `secretFields`:描述里的全部凭证配置项(脚本里拿到句柄,由本地网关替换)。 */
|
|
355
|
+
function secretFieldsOf(descriptor: ConnectorDescriptor | undefined): string[] {
|
|
356
|
+
if (!descriptor || !isRecord(descriptor.config)) return [];
|
|
357
|
+
return Object.entries(descriptor.config).filter(([, field]) => isRecord(field) && field.type === 'secret').map(([name]) => name);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** 拉取一个数据流(表取描述里该数据流的表,与线上相同)。`module` 是 `{ descriptor, implementations }`。 */
|
|
361
|
+
export function runStream(module: unknown, input: StreamRunInput): Promise<LocalRunResult> {
|
|
362
|
+
const descriptor = descriptorOf(module);
|
|
363
|
+
const stream = descriptor && Object.hasOwn(descriptor.streams, input.stream) ? descriptor.streams[input.stream] : undefined;
|
|
364
|
+
const job: GuestJob = {
|
|
365
|
+
kind: 'pull',
|
|
366
|
+
config: input.config,
|
|
367
|
+
secretFields: secretFieldsOf(descriptor),
|
|
368
|
+
stream: input.stream,
|
|
369
|
+
mode: input.mode ?? 'full',
|
|
370
|
+
state: input.state ?? null,
|
|
371
|
+
streamConfig: input.streamConfig ?? {},
|
|
372
|
+
tables: stream && isRecord(stream.tables) ? Object.keys(stream.tables) : [],
|
|
373
|
+
};
|
|
374
|
+
return createInProcessExecutor(module).run(job, input.host, input.limit !== undefined ? { limit: input.limit } : {});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function runCheck(module: unknown, input: { config: Record<string, unknown>; host: LocalHost }): Promise<LocalRunResult> {
|
|
378
|
+
return createInProcessExecutor(module).run({ kind: 'check', config: input.config, secretFields: secretFieldsOf(descriptorOf(module)) }, input.host);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 跑一次建议列(`suggestColumns`),结果在 `LocalRunResult.suggest`。 */
|
|
382
|
+
export function runSuggestColumns(module: unknown, input: { stream: string; table: string; config: Record<string, unknown>; host: LocalHost }): Promise<LocalRunResult> {
|
|
383
|
+
return createInProcessExecutor(module).run({
|
|
384
|
+
kind: 'suggest', config: input.config, secretFields: secretFieldsOf(descriptorOf(module)), stream: input.stream, table: input.table,
|
|
385
|
+
}, input.host);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface OperationRunInput {
|
|
389
|
+
config: Record<string, unknown>;
|
|
390
|
+
operation: string;
|
|
391
|
+
params: Record<string, unknown>;
|
|
392
|
+
idempotencyKey: string;
|
|
393
|
+
/** 缺省 `run`。 */
|
|
394
|
+
phase?: GuestOperationPhase;
|
|
395
|
+
/** 宿主调用(本地模拟网关或夹具回放)。 */
|
|
396
|
+
host: LocalHost;
|
|
397
|
+
/** 作业的凭证字段;缺省取执行器描述里的全部凭证配置项。 */
|
|
398
|
+
secretFields?: string[];
|
|
399
|
+
timeoutMs?: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 执行器层面的失败码 → 操作结果码:超时按 `CANCELED`(与 runner 超过截止时间杀进程一致),其余本地码按 `FATAL_ERROR`。 */
|
|
403
|
+
function operationFailureCode(code: string): Extract<GuestOperationResult, { ok: false }>['code'] {
|
|
404
|
+
if (code === 'TIMEOUT') return 'CANCELED';
|
|
405
|
+
const known: readonly string[] = [
|
|
406
|
+
'CONNECTOR_LOAD_FAILED', 'CONNECTOR_LOOKUP_REQUIRED', 'CONFIG_INVALID', 'SCHEMA_ERROR', 'FATAL_ERROR', 'AUTH_ERROR', 'TRANSIENT_ERROR', 'PUSH_UNKNOWN',
|
|
407
|
+
'SUGGEST_INVALID', 'CANCELED',
|
|
408
|
+
] satisfies readonly GuestFailureCode[];
|
|
409
|
+
return known.includes(code) ? code as GuestFailureCode : 'FATAL_ERROR';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 执行一次连接器操作(`run` / `lookup` / `simulate`),返回 `GuestOperationResult`。宿主调用交给 `input.host`
|
|
414
|
+
* (本地模拟网关时请求经网关、可挂录制器)。加载失败、超时等执行器层面的失败也折成 `ok: false` 的结果。
|
|
415
|
+
*/
|
|
416
|
+
export async function runOperation(executor: GuestExecutor, input: OperationRunInput): Promise<GuestOperationResult> {
|
|
417
|
+
const phase = input.phase ?? 'run';
|
|
418
|
+
const secretFields = input.secretFields ?? (phase === 'simulate' ? [] : secretFieldsOf(executor.descriptor));
|
|
419
|
+
const job: GuestJob = {
|
|
420
|
+
kind: 'operation', phase, config: input.config, secretFields,
|
|
421
|
+
operation: input.operation, params: input.params, idempotencyKey: input.idempotencyKey,
|
|
422
|
+
};
|
|
423
|
+
const result = await executor.run(job, input.host, input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {});
|
|
424
|
+
if (result.operation) return result.operation;
|
|
425
|
+
const error = result.error ?? { code: 'FATAL_ERROR', message: 'the connector returned no operation result' };
|
|
426
|
+
return { kind: 'operation', phase, ok: false, code: operationFailureCode(error.code), message: error.message, logs: result.logs.map((log) => ({ ...log })) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function runPush(
|
|
430
|
+
module: unknown,
|
|
431
|
+
input: {
|
|
432
|
+
push: string;
|
|
433
|
+
requests: Array<{ requestId: string; receivedAt: string; headers: Record<string, string>; body: string }>;
|
|
434
|
+
config: Record<string, unknown>;
|
|
435
|
+
host: LocalHost;
|
|
436
|
+
},
|
|
437
|
+
): Promise<LocalRunResult> {
|
|
438
|
+
const job: GuestJob = {
|
|
439
|
+
kind: 'push',
|
|
440
|
+
config: input.config,
|
|
441
|
+
secretFields: secretFieldsOf(descriptorOf(module)),
|
|
442
|
+
requests: input.requests.map((request) => ({ ...request, push: input.push })),
|
|
443
|
+
};
|
|
444
|
+
return createInProcessExecutor(module).run(job, input.host);
|
|
445
|
+
}
|