@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,45 @@
|
|
|
1
|
+
/** `ctx.map`:受控并发,上限取 `options.concurrency` 与生效并发(连接器 `rateLimit.concurrency`,缺省 4,再与连接覆盖取小)的较小者;结果保持输入顺序。 */
|
|
2
|
+
import { throwIfAborted } from './bridge';
|
|
3
|
+
import { DEFAULT_CONCURRENCY } from './limits';
|
|
4
|
+
|
|
5
|
+
export type MapFunction = <T, R>(items: readonly T[], fn: (item: T) => Promise<R>, options?: { concurrency?: number }) => Promise<R[]>;
|
|
6
|
+
|
|
7
|
+
function positiveInteger(value: unknown, what: string): number {
|
|
8
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) throw new TypeError(`${what} must be a positive integer`);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createMap(concurrency: number | null): MapFunction {
|
|
13
|
+
const cap = concurrency ?? DEFAULT_CONCURRENCY;
|
|
14
|
+
return async <T, R>(items: readonly T[], fn: (item: T) => Promise<R>, options?: { concurrency?: number }): Promise<R[]> => {
|
|
15
|
+
if (!Array.isArray(items)) throw new TypeError('ctx.map expects an array');
|
|
16
|
+
if (typeof fn !== 'function') throw new TypeError('ctx.map expects a function');
|
|
17
|
+
const requested = options?.concurrency === undefined ? cap : positiveInteger(options.concurrency, 'ctx.map concurrency');
|
|
18
|
+
const limit = Math.min(requested, cap);
|
|
19
|
+
const results: R[] = new Array<R>(items.length);
|
|
20
|
+
let next = 0;
|
|
21
|
+
let failed = false;
|
|
22
|
+
let firstError: unknown;
|
|
23
|
+
|
|
24
|
+
const worker = async (): Promise<void> => {
|
|
25
|
+
while (!failed && next < items.length) {
|
|
26
|
+
const index = next;
|
|
27
|
+
next += 1;
|
|
28
|
+
try {
|
|
29
|
+
throwIfAborted();
|
|
30
|
+
results[index] = await fn(items[index] as T);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (!failed) {
|
|
33
|
+
failed = true;
|
|
34
|
+
firstError = error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const workers: Array<Promise<void>> = [];
|
|
40
|
+
for (let i = 0; i < Math.min(limit, items.length); i += 1) workers.push(worker());
|
|
41
|
+
await Promise.all(workers);
|
|
42
|
+
if (failed) throw firstError;
|
|
43
|
+
return results;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 连接器操作作业(在 vm 上下文**里**执行)。结果总是 `GuestOperationResult`,失败也走 `result`:
|
|
3
|
+
* - `run`:校验连接配置、准备凭证,按 `input` 声明校验入参(不合格 `INPUT_INVALID`,不调 `run`),
|
|
4
|
+
* 调 `run(ctx, params, { idempotencyKey })`,按 `output` 声明校验返回值(不合格 `OUTPUT_INVALID`);
|
|
5
|
+
* - `lookup`:同样的准备与入参校验后调 `lookup(ctx, { idempotencyKey, params })`;抛错、回答不合法或 `applied` 的输出不合格一律按
|
|
6
|
+
* `{ status: 'unknown' }`;没有实现 `lookup` 时是带 `notImplemented: true` 的 `unknown`;
|
|
7
|
+
* - `simulate`:不取凭证、不给 `ctx`,期间任何宿主调用都被拒;只校验入参并调 `simulate(params)`,
|
|
8
|
+
* 没有实现 `simulate` 时 `FATAL_ERROR`(宿主据此回退缺省的模拟结果)。
|
|
9
|
+
*
|
|
10
|
+
* 错误归类按 `code`:`REJECTED`(带外部系统的业务码)、`INDETERMINATE`、`AUTH_ERROR`、`TRANSIENT_ERROR`(含 `RATE_LIMITED`)、
|
|
11
|
+
* `SCHEMA_ERROR`、`FATAL_ERROR`;其余按 `FATAL_ERROR`,运行已取消时一律 `CANCELED`。
|
|
12
|
+
*/
|
|
13
|
+
import type { ConnectorDescriptor, ValueField } from '../../descriptor';
|
|
14
|
+
import type { OperationContext } from '../../types';
|
|
15
|
+
import { validateConfig, validateOperationValue, type DeclarationIssue } from '../../validate';
|
|
16
|
+
import type { GuestOperationFailureCode, GuestOperationJob, GuestOperationResult, LookupAnswer } from '../protocol';
|
|
17
|
+
import { denyHostCalls, isAborted, jsonParse, jsonStringify, throwIfAborted } from './bridge';
|
|
18
|
+
import { createCache } from './cache';
|
|
19
|
+
import { deepFreeze, defaultBaseUrl, prepareSecrets } from './ctx';
|
|
20
|
+
import { createCrypto } from './crypto';
|
|
21
|
+
import { errorInfo } from './failure';
|
|
22
|
+
import { createHttp } from './http';
|
|
23
|
+
import type { LoadedConnector } from './loader';
|
|
24
|
+
import { createLogBuffer, type LogBuffer } from './log';
|
|
25
|
+
import { createTime } from './time';
|
|
26
|
+
|
|
27
|
+
type Outcome = { type: 'result'; result: GuestOperationResult };
|
|
28
|
+
|
|
29
|
+
/** 成功结果去掉 `kind` 与 `logs`(按联合逐个去掉)。 */
|
|
30
|
+
type Success = GuestOperationResult extends infer R ? R extends { ok: true } ? Omit<R, 'kind' | 'logs'> : never : never;
|
|
31
|
+
|
|
32
|
+
interface Failure { code: GuestOperationFailureCode; message: string; external?: { code?: string; message: string } }
|
|
33
|
+
|
|
34
|
+
/** 连接器抛出的值里能直接沿用的码;`RATE_LIMITED` 归到 `TRANSIENT_ERROR`。 */
|
|
35
|
+
const CODE_MAP: Readonly<Record<string, GuestOperationFailureCode>> = Object.freeze({
|
|
36
|
+
REJECTED: 'REJECTED',
|
|
37
|
+
INDETERMINATE: 'INDETERMINATE',
|
|
38
|
+
AUTH_ERROR: 'AUTH_ERROR',
|
|
39
|
+
TRANSIENT_ERROR: 'TRANSIENT_ERROR',
|
|
40
|
+
RATE_LIMITED: 'TRANSIENT_ERROR',
|
|
41
|
+
SCHEMA_ERROR: 'SCHEMA_ERROR',
|
|
42
|
+
FATAL_ERROR: 'FATAL_ERROR',
|
|
43
|
+
CANCELED: 'CANCELED',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function issuesText(issues: readonly DeclarationIssue[]): string {
|
|
51
|
+
return issues.map((issue) => `${issue.path || '(root)'}: ${issue.message}`).join('; ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 复制成纯 JSON 数据,切断与连接器对象(getter、原型)的联系;不可序列化时返回 undefined。 */
|
|
55
|
+
function plain(value: unknown): unknown {
|
|
56
|
+
let text: unknown;
|
|
57
|
+
try {
|
|
58
|
+
text = jsonStringify(value);
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return typeof text === 'string' ? jsonParse(text) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `RejectedError` 的 `details.code`(外部系统的业务码);读属性出错时不带。 */
|
|
66
|
+
function externalCode(error: unknown): string | undefined {
|
|
67
|
+
if (error === null || typeof error !== 'object') return undefined;
|
|
68
|
+
try {
|
|
69
|
+
const details = (error as { details?: unknown }).details;
|
|
70
|
+
if (isRecord(details) && typeof details.code === 'string') return details.code;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function failureOf(error: unknown): Failure {
|
|
78
|
+
if (isAborted()) return { code: 'CANCELED', message: 'run was canceled' };
|
|
79
|
+
const info = errorInfo(error);
|
|
80
|
+
const mapped = info.code !== null && Object.hasOwn(CODE_MAP, info.code) ? CODE_MAP[info.code] : undefined;
|
|
81
|
+
if (mapped === 'REJECTED') {
|
|
82
|
+
const code = externalCode(error);
|
|
83
|
+
return { code: 'REJECTED', message: info.message, external: { ...(code !== undefined ? { code } : {}), message: info.message } };
|
|
84
|
+
}
|
|
85
|
+
if (mapped) return { code: mapped, message: info.message };
|
|
86
|
+
return { code: 'FATAL_ERROR', message: info.code === null ? info.message : `${info.code}: ${info.message}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 操作的 `ctx`:没有 `record`、`db`、`paginate`、`map`、`signal`。 */
|
|
90
|
+
function createOperationContext(config: Record<string, unknown>, secrets: Readonly<Record<string, string>>, descriptor: ConnectorDescriptor, logs: LogBuffer): OperationContext {
|
|
91
|
+
const frozen = deepFreeze(config);
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
config: frozen,
|
|
94
|
+
secrets,
|
|
95
|
+
http: createHttp(defaultBaseUrl(descriptor, frozen)),
|
|
96
|
+
crypto: createCrypto(),
|
|
97
|
+
cache: createCache(),
|
|
98
|
+
time: createTime(),
|
|
99
|
+
log: logs.logger(),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 按描述的字段校验并复制一个值;不合格时返回问题。 */
|
|
104
|
+
function checked(schema: Record<string, ValueField>, value: unknown, path: 'input' | 'output'): { ok: true; value: Record<string, unknown> } | { ok: false; message: string } {
|
|
105
|
+
const copy = plain(value);
|
|
106
|
+
if (copy === undefined) return { ok: false, message: `${path} is not serializable` };
|
|
107
|
+
const issues = validateOperationValue(schema, copy, path);
|
|
108
|
+
if (issues.length > 0) return { ok: false, message: `${path} does not match the declaration: ${issuesText(issues)}` };
|
|
109
|
+
return { ok: true, value: copy as Record<string, unknown> };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** lookup 的回答规整:只认三种状态,`applied` 的输出按 `output` 声明校验,其余一律 `unknown`。 */
|
|
113
|
+
function lookupAnswer(output: Record<string, ValueField>, value: unknown, logs: LogBuffer): LookupAnswer {
|
|
114
|
+
const answer = plain(value);
|
|
115
|
+
if (!isRecord(answer)) return { status: 'unknown' };
|
|
116
|
+
if (answer.status === 'not_applied') return { status: 'not_applied' };
|
|
117
|
+
if (answer.status !== 'applied') return { status: 'unknown' };
|
|
118
|
+
const checkedOutput = checked(output, answer.output, 'output');
|
|
119
|
+
if (!checkedOutput.ok) {
|
|
120
|
+
logs.logger().warn(`lookup answered applied but ${checkedOutput.message}; treated as unknown`);
|
|
121
|
+
return { status: 'unknown' };
|
|
122
|
+
}
|
|
123
|
+
return { status: 'applied', output: checkedOutput.value };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runOperation(connector: LoadedConnector, job: GuestOperationJob): Promise<Outcome> {
|
|
127
|
+
const phase = job.phase;
|
|
128
|
+
const logs = createLogBuffer();
|
|
129
|
+
const done = (result: Success): Outcome =>
|
|
130
|
+
({ type: 'result', result: { kind: 'operation', ...result, logs: logs.drain() } as GuestOperationResult });
|
|
131
|
+
const fail = (failure: Failure): Outcome =>
|
|
132
|
+
({ type: 'result', result: { kind: 'operation', phase, ok: false, ...failure, logs: logs.drain() } as GuestOperationResult });
|
|
133
|
+
|
|
134
|
+
const operation = Object.hasOwn(connector.operations, job.operation) ? connector.operations[job.operation] : undefined;
|
|
135
|
+
const contract = Object.hasOwn(connector.descriptor.operations, job.operation) ? connector.descriptor.operations[job.operation] : undefined;
|
|
136
|
+
if (!operation || !contract) return fail({ code: 'CONNECTOR_LOAD_FAILED', message: `connector has no operation named ${job.operation}` });
|
|
137
|
+
|
|
138
|
+
if (phase === 'simulate') {
|
|
139
|
+
// 纯函数:不取凭证;从这里起任何宿主调用都被拒。
|
|
140
|
+
denyHostCalls();
|
|
141
|
+
if (typeof operation.simulate !== 'function') return fail({ code: 'FATAL_ERROR', message: `operation ${job.operation} does not implement simulate()` });
|
|
142
|
+
const params = checked(contract.input, job.params, 'input');
|
|
143
|
+
if (!params.ok) return fail({ code: 'INPUT_INVALID', message: params.message });
|
|
144
|
+
let value: unknown;
|
|
145
|
+
try {
|
|
146
|
+
value = await Reflect.apply(operation.simulate, operation, [deepFreeze(params.value)]);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return fail(failureOf(error));
|
|
149
|
+
}
|
|
150
|
+
const output = checked(contract.output, value, 'output');
|
|
151
|
+
if (!output.ok) return fail({ code: 'OUTPUT_INVALID', message: `simulate() returned an invalid result: ${output.message}` });
|
|
152
|
+
return done({ phase: 'simulate', ok: true, output: output.value });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const configIssues = validateConfig(connector.descriptor.config, job.config);
|
|
156
|
+
if (configIssues.length > 0) return fail({ code: 'CONFIG_INVALID', message: `invalid connection config: ${issuesText(configIssues)}` });
|
|
157
|
+
let secrets: Readonly<Record<string, string>>;
|
|
158
|
+
try {
|
|
159
|
+
secrets = await prepareSecrets(connector.descriptor, job.secretFields);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return fail(failureOf(error));
|
|
162
|
+
}
|
|
163
|
+
const params = checked(contract.input, job.params, 'input');
|
|
164
|
+
if (!params.ok) return fail({ code: 'INPUT_INVALID', message: params.message });
|
|
165
|
+
const ctx = createOperationContext(job.config, secrets, connector.descriptor, logs);
|
|
166
|
+
const idempotencyKey = job.idempotencyKey;
|
|
167
|
+
|
|
168
|
+
if (phase === 'lookup') {
|
|
169
|
+
if (typeof operation.lookup !== 'function') return done({ phase: 'lookup', ok: true, lookup: { status: 'unknown', notImplemented: true } });
|
|
170
|
+
let value: unknown;
|
|
171
|
+
try {
|
|
172
|
+
value = await Reflect.apply(operation.lookup, operation, [ctx, deepFreeze({ idempotencyKey, params: params.value })]);
|
|
173
|
+
throwIfAborted();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if (isAborted()) return fail({ code: 'CANCELED', message: 'run was canceled' });
|
|
176
|
+
const info = errorInfo(error);
|
|
177
|
+
logs.logger().warn(`lookup threw ${info.code ?? 'an error'}: ${info.message}; treated as unknown`);
|
|
178
|
+
return done({ phase: 'lookup', ok: true, lookup: { status: 'unknown' } });
|
|
179
|
+
}
|
|
180
|
+
return done({ phase: 'lookup', ok: true, lookup: lookupAnswer(contract.output, value, logs) });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let value: unknown;
|
|
184
|
+
try {
|
|
185
|
+
value = await Reflect.apply(operation.run, operation, [ctx, deepFreeze(params.value), Object.freeze({ idempotencyKey })]);
|
|
186
|
+
throwIfAborted();
|
|
187
|
+
} catch (error) {
|
|
188
|
+
return fail(failureOf(error));
|
|
189
|
+
}
|
|
190
|
+
const output = checked(contract.output, value, 'output');
|
|
191
|
+
if (!output.ok) return fail({ code: 'OUTPUT_INVALID', message: `run() returned an invalid result: ${output.message}` });
|
|
192
|
+
return done({ phase: 'run', ok: true, output: output.value });
|
|
193
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/** `ctx.paginate`:四种 HTTP 分页与数据库按键分页,都是产出 `Page` 的异步迭代器;取到空页即结束(空页不产出)。 */
|
|
2
|
+
import type {
|
|
3
|
+
CursorPaginateOptions, HttpClient, HttpResponse, KeysetPaginateOptions, LinkPaginateOptions, OffsetPaginateOptions, Page,
|
|
4
|
+
PagePaginateOptions, Paginate,
|
|
5
|
+
} from '../../types';
|
|
6
|
+
import { throwIfAborted } from './bridge';
|
|
7
|
+
|
|
8
|
+
function compare(a: unknown, b: unknown): number {
|
|
9
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
10
|
+
if (typeof a === 'bigint' && typeof b === 'bigint') return a < b ? -1 : a > b ? 1 : 0;
|
|
11
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
12
|
+
const left = String(a);
|
|
13
|
+
const right = String(b);
|
|
14
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function makePage<Row>(rows: Row[]): Page<Row> {
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
rows,
|
|
20
|
+
maxOf<V>(fn: (row: Row) => V): V | undefined {
|
|
21
|
+
let max: V | undefined;
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
const value = fn(row);
|
|
24
|
+
if (value === undefined || value === null) continue;
|
|
25
|
+
if (max === undefined || compare(value, max) > 0) max = value;
|
|
26
|
+
}
|
|
27
|
+
return max;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowsOf<Row>(value: unknown, what: string): Row[] {
|
|
33
|
+
if (!Array.isArray(value)) throw new TypeError(`${what} must return an array of rows`);
|
|
34
|
+
return value as Row[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requireFunction(value: unknown, what: string): void {
|
|
38
|
+
if (typeof value !== 'function') throw new TypeError(`${what} must be a function`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function* pageIterator<Res, Row>(options: PagePaginateOptions<Res, Row>): AsyncGenerator<Page<Row>> {
|
|
42
|
+
requireFunction(options.request, 'paginate.page request');
|
|
43
|
+
requireFunction(options.rows, 'paginate.page rows');
|
|
44
|
+
const first = options.firstPage ?? 1;
|
|
45
|
+
for (let page = first; ; page += 1) {
|
|
46
|
+
throwIfAborted();
|
|
47
|
+
const res = await options.request(page);
|
|
48
|
+
const rows = rowsOf<Row>(options.rows(res), 'paginate.page rows');
|
|
49
|
+
if (rows.length === 0) return;
|
|
50
|
+
yield makePage(rows);
|
|
51
|
+
if (options.totalPages) {
|
|
52
|
+
const total = options.totalPages(res);
|
|
53
|
+
if (typeof total !== 'number' || !Number.isFinite(total) || page - first + 1 >= total) return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function* offsetIterator<Res, Row>(options: OffsetPaginateOptions<Res, Row>): AsyncGenerator<Page<Row>> {
|
|
59
|
+
requireFunction(options.request, 'paginate.offset request');
|
|
60
|
+
requireFunction(options.rows, 'paginate.offset rows');
|
|
61
|
+
const limit = options.limit;
|
|
62
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) throw new TypeError('paginate.offset limit must be a positive integer');
|
|
63
|
+
for (let offset = options.startOffset ?? 0; ; offset += limit) {
|
|
64
|
+
throwIfAborted();
|
|
65
|
+
const res = await options.request(offset, limit);
|
|
66
|
+
const rows = rowsOf<Row>(options.rows(res), 'paginate.offset rows');
|
|
67
|
+
if (rows.length === 0) return;
|
|
68
|
+
yield makePage(rows);
|
|
69
|
+
if (options.total) {
|
|
70
|
+
const total = options.total(res);
|
|
71
|
+
if (typeof total !== 'number' || !Number.isFinite(total) || offset + limit >= total) return;
|
|
72
|
+
} else if (rows.length < limit) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function* cursorIterator<Res, Row>(options: CursorPaginateOptions<Res, Row>): AsyncGenerator<Page<Row>> {
|
|
79
|
+
requireFunction(options.request, 'paginate.cursor request');
|
|
80
|
+
requireFunction(options.rows, 'paginate.cursor rows');
|
|
81
|
+
requireFunction(options.nextCursor, 'paginate.cursor nextCursor');
|
|
82
|
+
let cursor: string | undefined;
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
for (;;) {
|
|
85
|
+
throwIfAborted();
|
|
86
|
+
const res = await options.request(cursor);
|
|
87
|
+
const rows = rowsOf<Row>(options.rows(res), 'paginate.cursor rows');
|
|
88
|
+
if (rows.length > 0) yield makePage(rows);
|
|
89
|
+
const next = options.nextCursor(res);
|
|
90
|
+
if (next === null || next === undefined || next === '') return;
|
|
91
|
+
const text = String(next);
|
|
92
|
+
// 来源回同一个游标会死循环。
|
|
93
|
+
if (seen.has(text)) throw new TypeError(`paginate.cursor got the same cursor twice: ${text}`);
|
|
94
|
+
seen.add(text);
|
|
95
|
+
cursor = text;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 解析 `Link` 头里 `rel="next"` 的地址。 */
|
|
100
|
+
export function nextLink(header: string | undefined): string | null {
|
|
101
|
+
if (!header) return null;
|
|
102
|
+
for (const part of header.split(',')) {
|
|
103
|
+
const match = /^\s*<([^>]*)>(.*)$/.exec(part);
|
|
104
|
+
if (!match) continue;
|
|
105
|
+
for (const param of (match[2] ?? '').split(';')) {
|
|
106
|
+
const rel = /^\s*rel\s*=\s*"?([^"]*)"?\s*$/i.exec(param);
|
|
107
|
+
if (rel && (rel[1] ?? '').toLowerCase().split(/\s+/).includes('next')) return match[1] ?? null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveAgainst(link: string, current: string): string {
|
|
114
|
+
if (/^https?:\/\//i.test(link)) return link;
|
|
115
|
+
const origin = /^(https?:\/\/[^/?#]+)/i.exec(current)?.[1];
|
|
116
|
+
if (!origin) return link;
|
|
117
|
+
if (link.startsWith('//')) return `${current.slice(0, current.indexOf(':') + 1)}${link}`;
|
|
118
|
+
if (link.startsWith('/')) return `${origin}${link}`;
|
|
119
|
+
const path = current.slice(origin.length).replace(/[?#].*$/, '');
|
|
120
|
+
const dir = path.slice(0, path.lastIndexOf('/') + 1) || '/';
|
|
121
|
+
return `${origin}${dir}${link}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function linkIterator<Res, Row>(http: HttpClient) {
|
|
125
|
+
return async function* (options: LinkPaginateOptions<Res, Row>): AsyncGenerator<Page<Row>> {
|
|
126
|
+
requireFunction(options.rows, 'paginate.link rows');
|
|
127
|
+
if (typeof options.url !== 'string') throw new TypeError('paginate.link url must be a string');
|
|
128
|
+
let url: string | null = options.url;
|
|
129
|
+
const seen = new Set<string>();
|
|
130
|
+
while (url !== null) {
|
|
131
|
+
throwIfAborted();
|
|
132
|
+
seen.add(url);
|
|
133
|
+
const res: HttpResponse<Res> = await http.get<Res>(url, options.options);
|
|
134
|
+
const rows = rowsOf<Row>(options.rows(res), 'paginate.link rows');
|
|
135
|
+
if (rows.length > 0) yield makePage(rows);
|
|
136
|
+
const next = options.next ? options.next(res) : nextLink(res.headers.link);
|
|
137
|
+
if (next === null || next === undefined || next === '') return;
|
|
138
|
+
const resolved = resolveAgainst(String(next), res.url || url);
|
|
139
|
+
if (seen.has(resolved)) throw new TypeError(`paginate.link got the same next link twice: ${resolved}`);
|
|
140
|
+
url = resolved;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function* keysetIterator<Row, Key>(options: KeysetPaginateOptions<Row, Key>): AsyncGenerator<Page<Row>> {
|
|
146
|
+
requireFunction(options.query, 'paginate.keyset query');
|
|
147
|
+
requireFunction(options.key, 'paginate.keyset key');
|
|
148
|
+
let last: Key | undefined;
|
|
149
|
+
for (;;) {
|
|
150
|
+
throwIfAborted();
|
|
151
|
+
const rows: Row[] = [];
|
|
152
|
+
for await (const row of options.query(last)) rows.push(row);
|
|
153
|
+
if (rows.length === 0) return;
|
|
154
|
+
yield makePage(rows);
|
|
155
|
+
const key = options.key(rows[rows.length - 1] as Row);
|
|
156
|
+
if (key === undefined || key === null) throw new TypeError('paginate.keyset key returned no value for the last row');
|
|
157
|
+
last = key;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createPaginate(http: HttpClient): Paginate {
|
|
162
|
+
return Object.freeze({
|
|
163
|
+
page: pageIterator,
|
|
164
|
+
offset: offsetIterator,
|
|
165
|
+
cursor: cursorIterator,
|
|
166
|
+
link: linkIterator(http),
|
|
167
|
+
keyset: keysetIterator,
|
|
168
|
+
}) as Paginate;
|
|
169
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 拉取作业:逐项拉取数据流生成器的产出,只认 `yield` 出来的 `ctx.record` / `ctx.remove` / `ctx.checkpoint` 标记。
|
|
3
|
+
* 记录按批经宿主调用 `emit` 交给宿主:每批最多 1000 条或 4 MiB(先到为准,字节数与服务端同口径,见 `itemBytes`);
|
|
4
|
+
* 单条就超过 4 MiB 的记录 / 删除标记发不出去,按条拒收(丢弃并记 warn 日志),条数随下一批的 `dropped` 上报、由服务端计入
|
|
5
|
+
* `stats.rejected`(流结束时只剩丢弃条数也发一批空批次);累计超过 `PULL_REJECT_THRESHOLD` 条时运行失败。
|
|
6
|
+
* 产出断点时把当前这一批连同断点立即发出,
|
|
7
|
+
* 宿主先写入断点之前的记录再提交断点(设计 §4.7),之后失败只丢断点之后的记录。
|
|
8
|
+
* 每次 `emit` 等宿主回复后才继续拉下一项(背压)。批次不带 `seq`,由宿主编号。
|
|
9
|
+
* 回复带 `{ stop: true }` 时(预览达到条数上限)触发 `ctx.signal`、不再拉取,结果为 `{ kind: 'pull', stopped: true, … }`。
|
|
10
|
+
*/
|
|
11
|
+
import { errors } from '../../errors';
|
|
12
|
+
import type { StreamInput } from '../../types';
|
|
13
|
+
import type { GuestJob, GuestPullBatch, GuestResult } from '../protocol';
|
|
14
|
+
import { call, isAborted, isStopped, jsonParse, jsonStringify, requestStop } from './bridge';
|
|
15
|
+
import { utf8Length } from './crypto';
|
|
16
|
+
import { createPullContext, deepFreeze, type ContextOptions } from './ctx';
|
|
17
|
+
import { failure, failureFromError, type FailureMessage } from './failure';
|
|
18
|
+
import { PULL_BATCH_MAX_BYTES, PULL_BATCH_MAX_RECORDS, PULL_REJECT_THRESHOLD } from './limits';
|
|
19
|
+
import type { LoadedConnector } from './loader';
|
|
20
|
+
|
|
21
|
+
type PullJob = Extract<GuestJob, { kind: 'pull' }>;
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 复制成纯 JSON 数据(连接器之后再改原对象也不影响已产出的记录),同时得到序列化文本。 */
|
|
28
|
+
function copy(value: unknown, what: string): { value: unknown; text: string } {
|
|
29
|
+
let text: unknown;
|
|
30
|
+
try {
|
|
31
|
+
text = jsonStringify(value);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
throw new errors.SchemaError(`${what} is not serializable: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
}
|
|
35
|
+
if (typeof text !== 'string') throw new errors.SchemaError(`${what} is not serializable`);
|
|
36
|
+
return { value: jsonParse(text), text };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PendingBatch {
|
|
40
|
+
records: GuestPullBatch['records'];
|
|
41
|
+
removals: GuestPullBatch['removals'];
|
|
42
|
+
checkpoint: { state: unknown } | undefined;
|
|
43
|
+
bytes: number;
|
|
44
|
+
/** 本批发出前丢弃的超大条数。 */
|
|
45
|
+
dropped: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 服务端按 `Buffer.byteLength(JSON.stringify([records, removals]))` 校验一批(≤ `PULL_BATCH_MAX_BYTES`,
|
|
50
|
+
* 见 connector 的 `runner-protocol/routes.ts`),这里按同一形状计数:
|
|
51
|
+
* 外层 `[[…],[…]]` 固定 5 字节;每项 `{"table":<表名>,"row":<值>}` / `{"table":<表名>,"key":<值>}` 的框架 17 字节,
|
|
52
|
+
* 加表名与值的 JSON 字节数,再按每项 1 个逗号计(每个数组最后一项其实没有逗号,最多多算 2 字节,只会偏保守)。
|
|
53
|
+
*/
|
|
54
|
+
const BATCH_FRAME_BYTES = 5;
|
|
55
|
+
const ITEM_FRAME_BYTES = 17 + 1;
|
|
56
|
+
|
|
57
|
+
function itemBytes(table: string, valueText: string): number {
|
|
58
|
+
return ITEM_FRAME_BYTES + utf8Length(jsonStringify(table)) + utf8Length(valueText);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emptyBatch(): PendingBatch {
|
|
62
|
+
return { records: [], removals: [], checkpoint: undefined, bytes: BATCH_FRAME_BYTES, dropped: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function closeIterator(iterator: AsyncIterator<unknown>): void {
|
|
66
|
+
try {
|
|
67
|
+
const closing = iterator.return?.();
|
|
68
|
+
if (closing && typeof closing.then === 'function') closing.then(undefined, () => undefined);
|
|
69
|
+
} catch {
|
|
70
|
+
// 生成器收尾出错不影响失败回报
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runPull(connector: LoadedConnector, job: PullJob, options: ContextOptions): Promise<{ type: 'result'; result: GuestResult } | FailureMessage> {
|
|
75
|
+
const streams = connector.streams;
|
|
76
|
+
const stream = streams[job.stream];
|
|
77
|
+
if (!stream) return failure('CONNECTOR_LOAD_FAILED', `connector has no stream named ${job.stream}`, options.logs.drain());
|
|
78
|
+
const ctx = createPullContext(options);
|
|
79
|
+
const input: StreamInput = Object.freeze({
|
|
80
|
+
mode: job.mode,
|
|
81
|
+
state: job.state === null ? undefined : deepFreeze(job.state),
|
|
82
|
+
streamConfig: deepFreeze(job.streamConfig),
|
|
83
|
+
});
|
|
84
|
+
const tables = new Set(job.tables);
|
|
85
|
+
let batch = emptyBatch();
|
|
86
|
+
let emitted = 0;
|
|
87
|
+
|
|
88
|
+
const flush = async (): Promise<void> => {
|
|
89
|
+
const payload: GuestPullBatch = {
|
|
90
|
+
records: batch.records,
|
|
91
|
+
removals: batch.removals,
|
|
92
|
+
...(batch.checkpoint ? { checkpoint: batch.checkpoint } : {}),
|
|
93
|
+
logs: options.logs.drain(),
|
|
94
|
+
...(batch.dropped > 0 ? { dropped: batch.dropped } : {}),
|
|
95
|
+
};
|
|
96
|
+
batch = emptyBatch();
|
|
97
|
+
const reply = await call('emit', payload);
|
|
98
|
+
emitted += 1;
|
|
99
|
+
if (isRecord(reply) && reply.stop === true) requestStop();
|
|
100
|
+
};
|
|
101
|
+
const result = (): { type: 'result'; result: GuestResult } => ({
|
|
102
|
+
type: 'result',
|
|
103
|
+
result: { kind: 'pull', emittedBatches: emitted, ...(isStopped() ? { stopped: true } : {}), logs: options.logs.drain() },
|
|
104
|
+
});
|
|
105
|
+
let oversized = 0;
|
|
106
|
+
const log = options.logs.logger();
|
|
107
|
+
const add = async (what: 'record' | 'removal', table: string, size: number, push: () => void): Promise<void> => {
|
|
108
|
+
// 单条放进空批次也超限:服务端会整批拒绝(400),只能按条拒收,不让整次运行失败。
|
|
109
|
+
if (BATCH_FRAME_BYTES + size > PULL_BATCH_MAX_BYTES) {
|
|
110
|
+
oversized += 1;
|
|
111
|
+
batch.dropped += 1;
|
|
112
|
+
log.warn(`RECORD_TOO_LARGE: ${what} for table "${table}" is ${size} bytes, more than the ${PULL_BATCH_MAX_BYTES}-byte batch limit; it was dropped`);
|
|
113
|
+
if (oversized > PULL_REJECT_THRESHOLD) {
|
|
114
|
+
throw new errors.SchemaError(`stream ${job.stream} produced ${oversized} records larger than ${PULL_BATCH_MAX_BYTES} bytes, more than ${PULL_REJECT_THRESHOLD}`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (batch.records.length + batch.removals.length > 0 && batch.bytes + size > PULL_BATCH_MAX_BYTES) await flush();
|
|
119
|
+
push();
|
|
120
|
+
batch.bytes += size;
|
|
121
|
+
if (batch.records.length + batch.removals.length >= PULL_BATCH_MAX_RECORDS) await flush();
|
|
122
|
+
};
|
|
123
|
+
const checkTable = (table: unknown): string => {
|
|
124
|
+
if (typeof table !== 'string' || !tables.has(table)) {
|
|
125
|
+
throw new errors.SchemaError(`stream ${job.stream} produced a record for table ${String(table)}, which is not declared for this stream`);
|
|
126
|
+
}
|
|
127
|
+
return table;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
let iterator: AsyncIterator<unknown>;
|
|
131
|
+
try {
|
|
132
|
+
const iterable = Reflect.apply(stream, undefined, [ctx, input]) as unknown;
|
|
133
|
+
const factory = isRecord(iterable) || typeof iterable === 'function' ? (iterable as Partial<AsyncIterable<unknown>>)[Symbol.asyncIterator] : undefined;
|
|
134
|
+
if (typeof factory !== 'function') throw new errors.SchemaError(`stream ${job.stream} must be an async generator function`);
|
|
135
|
+
iterator = Reflect.apply(factory, iterable, []) as AsyncIterator<unknown>;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return failureFromError(error, options.logs.drain());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
for (;;) {
|
|
142
|
+
if (isAborted()) {
|
|
143
|
+
closeIterator(iterator);
|
|
144
|
+
return isStopped() ? result() : failure('CANCELED', 'run was canceled', options.logs.drain());
|
|
145
|
+
}
|
|
146
|
+
const step = await iterator.next();
|
|
147
|
+
if (step.done) break;
|
|
148
|
+
const item: unknown = step.value;
|
|
149
|
+
const kind = isRecord(item) ? item.__ontos : undefined;
|
|
150
|
+
if (kind === 'record' && isRecord(item)) {
|
|
151
|
+
const table = checkTable(item.table);
|
|
152
|
+
if (!isRecord(item.row)) throw new errors.SchemaError(`stream ${job.stream} produced a record whose row is not an object`);
|
|
153
|
+
const row = copy(item.row, 'row');
|
|
154
|
+
await add('record', table, itemBytes(table, row.text), () => batch.records.push({ table, row: row.value as Record<string, unknown> }));
|
|
155
|
+
} else if (kind === 'remove' && isRecord(item)) {
|
|
156
|
+
const table = checkTable(item.table);
|
|
157
|
+
if (!isRecord(item.key)) throw new errors.SchemaError(`stream ${job.stream} produced a removal whose key is not an object`);
|
|
158
|
+
const key = copy(item.key, 'key');
|
|
159
|
+
await add('removal', table, itemBytes(table, key.text), () => batch.removals.push({ table, key: key.value as Record<string, unknown> }));
|
|
160
|
+
} else if (kind === 'checkpoint' && isRecord(item)) {
|
|
161
|
+
batch.checkpoint = { state: copy(item.state, 'checkpoint state').value };
|
|
162
|
+
// 断点之前的记录随断点一起发出,不等凑满一批:页小于一批时也能逐页提交断点。
|
|
163
|
+
await flush();
|
|
164
|
+
} else {
|
|
165
|
+
throw new errors.SchemaError(`stream ${job.stream} yielded a value that is not ctx.record(), ctx.remove() or ctx.checkpoint()`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (isAborted()) return isStopped() ? result() : failure('CANCELED', 'run was canceled', options.logs.drain());
|
|
169
|
+
if (batch.records.length + batch.removals.length > 0 || batch.dropped > 0) await flush();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
closeIterator(iterator);
|
|
172
|
+
// 提前结束之后生成器收尾抛出的错误(如在途宿主调用被拒)不算失败。
|
|
173
|
+
if (isStopped()) return result();
|
|
174
|
+
return failureFromError(error, options.logs.drain());
|
|
175
|
+
}
|
|
176
|
+
return result();
|
|
177
|
+
}
|