@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,444 @@
|
|
|
1
|
+
/** 作业的执行:加载核对 / push / pull / suggest / check。 */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import type { GuestPullBatch } from '../../src/runtime';
|
|
4
|
+
import { configField, descriptor, push, stream, table } from '../fixtures/descriptor';
|
|
5
|
+
import { checkJob, expectFailure, expectResult, pullJob, pushJob, rawConnector, runGuest, suggestJob, type RunResult } from './helpers';
|
|
6
|
+
|
|
7
|
+
const ITEM = { item: table({ id: 'string' }, ['id']) };
|
|
8
|
+
|
|
9
|
+
function pushConnector(handler: string): string {
|
|
10
|
+
return rawConnector(descriptor({ pushes: { default: push(ITEM) } }), `pushes: { default: definePush('default', ${handler}) },`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function emits(run: RunResult): GuestPullBatch[] {
|
|
14
|
+
return run.calls.filter((entry) => entry.method === 'emit').map((entry) => entry.payload as GuestPullBatch);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('加载:实现与描述核对', () => {
|
|
18
|
+
const desc = descriptor({
|
|
19
|
+
hosts: ['a.x.com'],
|
|
20
|
+
streams: { orders: stream({ orders: table({ id: 'string' }, ['id']) }), lines: stream({ lines: table({ id: 'string' }, ['id']) }) },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('描述有数据流 orders 而实现里没有:任一作业都 CONNECTOR_LOAD_FAILED,消息列出缺失与多余的实现', async () => {
|
|
24
|
+
const code = rawConnector(desc, `streams: { lines: defineStream('lines', async function* () {}), extra: defineStream('extra', async function* () {}) },`);
|
|
25
|
+
for (const job of [checkJob(), pullJob({ stream: 'lines', tables: ['lines'] }), pushJob([])]) {
|
|
26
|
+
const failure = expectFailure((await runGuest({ code, job })).outcome);
|
|
27
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
28
|
+
expect(failure.message).toContain('missing streams/orders');
|
|
29
|
+
expect(failure.message).toContain('extra streams/extra');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('实现的名字与所在目录不一致时 CONNECTOR_LOAD_FAILED', async () => {
|
|
34
|
+
const code = rawConnector(desc, `streams: { orders: defineStream('lines', async function* () {}), lines: defineStream('lines', async function* () {}) },`);
|
|
35
|
+
const failure = expectFailure((await runGuest({ code, job: checkJob() })).outcome);
|
|
36
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
37
|
+
expect(failure.message).toContain('streams/orders/index.ts');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('描述要求 check 而没有实现,或没有要求却给了:CONNECTOR_LOAD_FAILED', async () => {
|
|
41
|
+
const streams = `streams: { orders: defineStream('orders', async function* () {}), lines: defineStream('lines', async function* () {}) },`;
|
|
42
|
+
const missing = expectFailure((await runGuest({ code: rawConnector({ ...desc, check: true }, streams), job: checkJob() })).outcome);
|
|
43
|
+
expect(missing.message).toContain('missing check');
|
|
44
|
+
const extra = expectFailure((await runGuest({ code: rawConnector(desc, `${streams} check: defineCheck(() => {}),`), job: checkJob() })).outcome);
|
|
45
|
+
expect(extra.message).toContain('extra check');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('产物的名字或版本与期望不同时 CONNECTOR_LOAD_FAILED', async () => {
|
|
49
|
+
const code = rawConnector(descriptor({ version: '2.0.0', pushes: { default: push(ITEM) } }), `pushes: { default: definePush('default', { map() {} }) },`);
|
|
50
|
+
const failure = expectFailure((await runGuest({ code, job: pushJob([]) })).outcome);
|
|
51
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
52
|
+
expect(failure.message).toContain('demo@2.0.0');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('产物没有导出 descriptor 时 CONNECTOR_LOAD_FAILED', async () => {
|
|
56
|
+
const failure = expectFailure((await runGuest({ code: 'module.exports = { implementations: {} };', job: checkJob() })).outcome);
|
|
57
|
+
expect(failure.code).toBe('CONNECTOR_LOAD_FAILED');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('push 作业', () => {
|
|
62
|
+
test('推送名不在描述里的请求拒收 PUSH_UNKNOWN,其余照常产出', async () => {
|
|
63
|
+
const code = pushConnector(`{ map(ctx, req) { ctx.record('item', req.json()); } }`);
|
|
64
|
+
const run = await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{"id":"a"}' }, { requestId: 'r2', push: 'orders', body: '{"id":"b"}' }]) });
|
|
65
|
+
const result = expectResult(run.outcome);
|
|
66
|
+
expect(result).toMatchObject({
|
|
67
|
+
kind: 'push',
|
|
68
|
+
records: [{ requestId: 'r1', table: 'item', row: { id: 'a' } }],
|
|
69
|
+
rejected: [{ requestId: 'r2', code: 'PUSH_UNKNOWN' }],
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('先 verify 再 map;单条请求失败只拒收该条并丢弃其记录;请求结束后 ctx 不可用', async () => {
|
|
74
|
+
const code = pushConnector(`{
|
|
75
|
+
verify(ctx, req) { ctx.log.info('verify ' + req.id); if (req.body === 'bad-sign') throw new errors.RejectedPayload('signature mismatch'); },
|
|
76
|
+
map(ctx, req) {
|
|
77
|
+
ctx.log.info('map ' + req.id);
|
|
78
|
+
if (req.body === 'keep') { globalThis.kept = ctx; return; }
|
|
79
|
+
ctx.record('item', { id: req.id });
|
|
80
|
+
if (req.body === 'boom') throw new Error('plain failure');
|
|
81
|
+
let late = 'none';
|
|
82
|
+
try { globalThis.kept.record('item', { id: 'late' }); } catch (e) { late = e.name; }
|
|
83
|
+
ctx.remove('item', { id: req.id + '-old', late });
|
|
84
|
+
},
|
|
85
|
+
}`);
|
|
86
|
+
const run = await runGuest({ code, job: pushJob([
|
|
87
|
+
{ requestId: 'r1', body: 'keep' }, { requestId: 'r2', body: 'bad-sign' }, { requestId: 'r3', body: 'boom' }, { requestId: 'r4', body: 'ok' },
|
|
88
|
+
]) });
|
|
89
|
+
const result = expectResult(run.outcome);
|
|
90
|
+
if (result.kind !== 'push') throw new Error('expected push');
|
|
91
|
+
expect(result.records).toEqual([{ requestId: 'r4', table: 'item', row: { id: 'r4' } }]);
|
|
92
|
+
expect(result.removals).toEqual([{ requestId: 'r4', table: 'item', key: { id: 'r4-old', late: 'TypeError' } }]);
|
|
93
|
+
expect(result.rejected).toEqual([
|
|
94
|
+
{ requestId: 'r2', code: 'REJECTED_PAYLOAD', message: 'signature mismatch' },
|
|
95
|
+
{ requestId: 'r3', code: 'CONNECTOR_ERROR', message: 'plain failure' },
|
|
96
|
+
]);
|
|
97
|
+
expect(result.logs.map((log) => `${log.requestId}:${log.message}`)).toEqual([
|
|
98
|
+
'r1:verify r1', 'r1:map r1', 'r2:verify r2', 'r3:verify r3', 'r3:map r3', 'r4:verify r4', 'r4:map r4',
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('SCHEMA_ERROR / FATAL_ERROR 让整次失败', async () => {
|
|
103
|
+
for (const kind of ['SchemaError', 'FatalError']) {
|
|
104
|
+
const code = pushConnector(`{ map() { throw new errors.${kind}('stop'); } }`);
|
|
105
|
+
const failure = expectFailure((await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }]) })).outcome);
|
|
106
|
+
expect(failure).toMatchObject({ code: kind === 'SchemaError' ? 'SCHEMA_ERROR' : 'FATAL_ERROR', message: 'stop' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('没有推送的连接器做推送作业时加载失败', async () => {
|
|
111
|
+
const code = rawConnector(descriptor({ hosts: ['a.x.com'], streams: { items: stream(ITEM) } }), `streams: { items: defineStream('items', async function* () {}) },`);
|
|
112
|
+
expect(expectFailure((await runGuest({ code, job: pushJob([]) })).outcome).code).toBe('CONNECTOR_LOAD_FAILED');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('记录写了不属于该推送的字段:照常交给宿主(由宿主丢弃)', async () => {
|
|
116
|
+
const code = pushConnector(`{ map(ctx, req) { ctx.record('item', { id: req.id, foo: 1 }); } }`);
|
|
117
|
+
const result = expectResult((await runGuest({ code, job: pushJob([{ requestId: 'r1', body: '{}' }]) })).outcome);
|
|
118
|
+
expect(result).toMatchObject({ kind: 'push', records: [{ requestId: 'r1', table: 'item', row: { id: 'r1', foo: 1 } }] });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('pull 作业', () => {
|
|
123
|
+
const desc = descriptor({
|
|
124
|
+
hosts: ['a.x.com'],
|
|
125
|
+
streams: { items: stream({ item: table({ id: 'integer' }, ['id']), line: table({ id: 'integer' }, ['id']) }) },
|
|
126
|
+
});
|
|
127
|
+
function streamConnector(body: string): string {
|
|
128
|
+
return rawConnector(desc, `streams: { items: defineStream('items', async function* (ctx, input) { ${body} }) },`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test('2500 条记录加 1 个断点:3 次 emit(1000、1000、500),断点附在最后一批,每次等回复后才继续', async () => {
|
|
132
|
+
const code = streamConnector(`
|
|
133
|
+
ctx.log.info('start ' + input.mode + ' ' + JSON.stringify(input.state) + ' ' + input.streamConfig.org);
|
|
134
|
+
for (let i = 0; i < 2500; i += 1) yield ctx.record('item', { id: i });
|
|
135
|
+
yield ctx.checkpoint({ cursor: 2500 });`);
|
|
136
|
+
let inFlight = false;
|
|
137
|
+
let overlapped = false;
|
|
138
|
+
const run = await runGuest({
|
|
139
|
+
code, job: pullJob({ mode: 'incremental', state: { cursor: 0 }, streamConfig: { org: 'o1' }, tables: ['item', 'line'] }),
|
|
140
|
+
host: {
|
|
141
|
+
emit: async () => {
|
|
142
|
+
if (inFlight) overlapped = true;
|
|
143
|
+
inFlight = true;
|
|
144
|
+
await Bun.sleep(20);
|
|
145
|
+
inFlight = false;
|
|
146
|
+
return { seq: 0, duplicate: false, rejected: 0 };
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const result = expectResult(run.outcome);
|
|
151
|
+
const batches = emits(run);
|
|
152
|
+
expect(batches.map((batch) => batch.records.length)).toEqual([1000, 1000, 500]);
|
|
153
|
+
expect(batches.map((batch) => batch.checkpoint)).toEqual([undefined, undefined, { state: { cursor: 2500 } }]);
|
|
154
|
+
expect(batches[0]?.logs.map((log) => log.message)).toEqual(['start incremental {"cursor":0} o1']);
|
|
155
|
+
expect(batches[2]?.records[499]).toEqual({ table: 'item', row: { id: 2499 } });
|
|
156
|
+
expect(overlapped).toBe(false);
|
|
157
|
+
expect(result).toEqual({ kind: 'pull', emittedBatches: 3, logs: [] });
|
|
158
|
+
// emit 之后才发 result
|
|
159
|
+
expect(run.messages.at(-1)?.type).toBe('result');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('断点立即连同之前的记录发出:3 页各 5 条、每页一个断点 → 3 批;之后抛错只丢断点之后的记录', async () => {
|
|
163
|
+
const code = streamConnector(`
|
|
164
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
165
|
+
for (let i = 0; i < 5; i += 1) yield ctx.record('item', { id: page * 10 + i });
|
|
166
|
+
yield ctx.checkpoint({ page });
|
|
167
|
+
}
|
|
168
|
+
yield ctx.record('item', { id: 99 });
|
|
169
|
+
throw new errors.FatalError('page 4 failed');`);
|
|
170
|
+
const run = await runGuest({ code, job: pullJob(), host: { emit: () => ({ seq: 0, duplicate: false, rejected: 0 }) } });
|
|
171
|
+
expect(expectFailure(run.outcome)).toMatchObject({ code: 'FATAL_ERROR', message: 'page 4 failed' });
|
|
172
|
+
const batches = emits(run);
|
|
173
|
+
expect(batches.map((batch) => [batch.records.length, batch.checkpoint])).toEqual([
|
|
174
|
+
[5, { state: { page: 1 } }], [5, { state: { page: 2 } }], [5, { state: { page: 3 } }],
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('背压:emit 回复之前生成器不会继续产出', async () => {
|
|
179
|
+
const code = streamConnector(`
|
|
180
|
+
for (let i = 0; i < 1000; i += 1) yield ctx.record('item', { id: i });
|
|
181
|
+
ctx.log.info('after first batch');
|
|
182
|
+
yield ctx.record('item', { id: 1000 });`);
|
|
183
|
+
const seen: string[][] = [];
|
|
184
|
+
const run = await runGuest({ code, job: pullJob(), host: { emit: (payload) => { seen.push((payload as GuestPullBatch).logs.map((log) => log.message)); return null; } } });
|
|
185
|
+
expectResult(run.outcome);
|
|
186
|
+
expect(seen).toEqual([[], ['after first batch']]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('累计超过 4 MiB 而未满 1000 条时先发出这一批', async () => {
|
|
190
|
+
const code = streamConnector(`
|
|
191
|
+
const blob = 'x'.repeat(1024 * 1024);
|
|
192
|
+
for (let i = 0; i < 6; i += 1) yield ctx.record('item', { id: i, blob });`);
|
|
193
|
+
const run = await runGuest({ code, job: pullJob(), host: { emit: () => null } });
|
|
194
|
+
expectResult(run.outcome);
|
|
195
|
+
expect(emits(run).map((batch) => batch.records.map((record) => record.row.id))).toEqual([[0, 1, 2], [3, 4, 5]]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('批次字节数与服务端同口径:约 6 KB 的宽行与删除标记混合,每批都不超过服务端的 4 MiB 校验', async () => {
|
|
199
|
+
const MAX = 4 * 1024 * 1024;
|
|
200
|
+
const code = streamConnector(`
|
|
201
|
+
const blob = 'y'.repeat(6000);
|
|
202
|
+
for (let i = 0; i < 1500; i += 1) {
|
|
203
|
+
yield ctx.record('item', { id: i, blob });
|
|
204
|
+
if (i % 3 === 0) yield ctx.remove('line', { id: i });
|
|
205
|
+
}`);
|
|
206
|
+
const run = await runGuest({ code, job: pullJob({ tables: ['item', 'line'] }), host: { emit: () => null } });
|
|
207
|
+
expectResult(run.outcome);
|
|
208
|
+
const batches = emits(run);
|
|
209
|
+
// 服务端 runner-protocol/routes.ts 的 readBatch:Buffer.byteLength(JSON.stringify([records, removals])) ≤ 4 MiB
|
|
210
|
+
for (const batch of batches) expect(Buffer.byteLength(JSON.stringify([batch.records, batch.removals]))).toBeLessThanOrEqual(MAX);
|
|
211
|
+
expect(batches.reduce((sum, batch) => sum + batch.records.length, 0)).toBe(1500);
|
|
212
|
+
// 批次仍然尽量装满:除最后一批外,再加一条 6 KB 的记录就会超限
|
|
213
|
+
for (const batch of batches.slice(0, -1)) expect(Buffer.byteLength(JSON.stringify([batch.records, batch.removals]))).toBeGreaterThan(MAX - 6100);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('单条超过 4 MiB 的记录按条拒收:丢弃并记 warn,其余记录照常发出,运行成功', async () => {
|
|
217
|
+
const code = streamConnector(`
|
|
218
|
+
yield ctx.record('item', { id: 1 });
|
|
219
|
+
yield ctx.record('item', { id: 2, blob: 'z'.repeat(5 * 1024 * 1024) });
|
|
220
|
+
yield ctx.remove('line', { id: 3, blob: 'z'.repeat(5 * 1024 * 1024) });
|
|
221
|
+
yield ctx.record('item', { id: 4 });`);
|
|
222
|
+
const run = await runGuest({ code, job: pullJob({ tables: ['item', 'line'] }), host: { emit: () => null } });
|
|
223
|
+
expectResult(run.outcome);
|
|
224
|
+
const batches = emits(run);
|
|
225
|
+
expect(batches.map((batch) => batch.records.map((record) => record.row.id))).toEqual([[1, 4]]);
|
|
226
|
+
expect(batches[0]?.removals).toEqual([]);
|
|
227
|
+
// 丢弃的条数随批次上报,服务端计入 stats.rejected
|
|
228
|
+
expect(batches[0]?.dropped).toBe(2);
|
|
229
|
+
const warnings = batches.flatMap((batch) => batch.logs).filter((entry) => entry.level === 'warn').map((entry) => entry.message);
|
|
230
|
+
expect(warnings).toHaveLength(2);
|
|
231
|
+
expect(warnings[0]).toContain('RECORD_TOO_LARGE');
|
|
232
|
+
expect(warnings[0]).toContain('"item"');
|
|
233
|
+
expect(warnings[1]).toContain('"line"');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('断点之后只剩被丢弃的超大记录:仍发一批空批次上报丢弃条数', async () => {
|
|
237
|
+
const code = streamConnector(`
|
|
238
|
+
yield ctx.record('item', { id: 1 });
|
|
239
|
+
yield ctx.checkpoint({ page: 1 });
|
|
240
|
+
yield ctx.record('item', { id: 2, blob: 'z'.repeat(5 * 1024 * 1024) });`);
|
|
241
|
+
const run = await runGuest({ code, job: pullJob({ tables: ['item'] }), host: { emit: () => null } });
|
|
242
|
+
expectResult(run.outcome);
|
|
243
|
+
const batches = emits(run);
|
|
244
|
+
expect(batches).toHaveLength(2);
|
|
245
|
+
expect(batches[0]).toMatchObject({ records: [{ table: 'item', row: { id: 1 } }], checkpoint: { state: { page: 1 } } });
|
|
246
|
+
expect(batches[0]?.dropped).toBeUndefined();
|
|
247
|
+
expect(batches[1]).toMatchObject({ records: [], removals: [], dropped: 1 });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('删除标记同样入批;记录在产出之后被改动不影响已产出的值', async () => {
|
|
251
|
+
const code = streamConnector(`
|
|
252
|
+
const row = { id: 1 };
|
|
253
|
+
yield ctx.record('item', row);
|
|
254
|
+
row.id = 2;
|
|
255
|
+
yield ctx.remove('line', { id: 9 });`);
|
|
256
|
+
const run = await runGuest({ code, job: pullJob({ tables: ['item', 'line'] }), host: { emit: () => null } });
|
|
257
|
+
expectResult(run.outcome);
|
|
258
|
+
expect(emits(run)).toEqual([{ records: [{ table: 'item', row: { id: 1 } }], removals: [{ table: 'line', key: { id: 9 } }], logs: [] }]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test('记录落在 job.tables 以外的表时 SCHEMA_ERROR,消息带表名', async () => {
|
|
262
|
+
const code = streamConnector(`yield ctx.record('line', { id: 1 });`);
|
|
263
|
+
const failure = expectFailure((await runGuest({ code, job: pullJob({ tables: ['item'] }), host: { emit: () => null } })).outcome);
|
|
264
|
+
expect(failure.code).toBe('SCHEMA_ERROR');
|
|
265
|
+
expect(failure.message).toContain('table line');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('生成器产出非 StreamItem 的值时 SCHEMA_ERROR', async () => {
|
|
269
|
+
for (const value of [`{ id: 1 }`, `{ __ontos: 'record', table: 'item', row: [] }`, `42`]) {
|
|
270
|
+
const failure = expectFailure((await runGuest({ code: streamConnector(`yield ${value};`), job: pullJob() })).outcome);
|
|
271
|
+
expect(failure.code).toBe('SCHEMA_ERROR');
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('未知数据流名时 CONNECTOR_LOAD_FAILED', async () => {
|
|
276
|
+
const run = await runGuest({ code: streamConnector(`yield ctx.record('item', { id: 1 });`), job: pullJob({ stream: 'missing' }) });
|
|
277
|
+
expect(expectFailure(run.outcome).code).toBe('CONNECTOR_LOAD_FAILED');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('生成器正常结束:先发剩余记录的 emit,再发 result;只有断点也会发', async () => {
|
|
281
|
+
const run = await runGuest({ code: streamConnector(`yield ctx.record('item', { id: 1 }); ctx.log.info('done');`), job: pullJob(), host: { emit: () => null } });
|
|
282
|
+
expect(run.messages.map((message) => (message.type === 'call' ? message.method : message.type))).toEqual(['emit', 'result']);
|
|
283
|
+
expect(emits(run)[0]?.logs.map((log) => log.message)).toEqual(['done']);
|
|
284
|
+
|
|
285
|
+
const onlyCheckpoint = await runGuest({ code: streamConnector(`yield ctx.checkpoint({ at: 1 });`), job: pullJob(), host: { emit: () => null } });
|
|
286
|
+
expect(emits(onlyCheckpoint)).toEqual([{ records: [], removals: [], checkpoint: { state: { at: 1 } }, logs: [] }]);
|
|
287
|
+
|
|
288
|
+
const empty = await runGuest({ code: streamConnector(`ctx.log.info('nothing');`), job: pullJob() });
|
|
289
|
+
expect(emits(empty)).toEqual([]);
|
|
290
|
+
expect(expectResult(empty.outcome)).toMatchObject({ emittedBatches: 0, logs: [{ message: 'nothing' }] });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('生成器抛 TransientError 时 TRANSIENT_ERROR,未发出的记录丢弃', async () => {
|
|
294
|
+
const run = await runGuest({ code: streamConnector(`yield ctx.record('item', { id: 1 }); throw new errors.TransientError('later');`), job: pullJob() });
|
|
295
|
+
expect(expectFailure(run.outcome)).toMatchObject({ code: 'TRANSIENT_ERROR', message: 'later' });
|
|
296
|
+
expect(emits(run)).toEqual([]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('收到 cancel 后生成器还在产出:停止拉取下一项,回 CANCELED', async () => {
|
|
300
|
+
const code = streamConnector(`
|
|
301
|
+
for (let i = 0; ; i += 1) {
|
|
302
|
+
yield ctx.record('item', { id: i });
|
|
303
|
+
if (i === 1500) globalThis.reached = true;
|
|
304
|
+
}`);
|
|
305
|
+
let pulledAfterCancel = 0;
|
|
306
|
+
const run = await runGuest({
|
|
307
|
+
code, job: pullJob(),
|
|
308
|
+
host: {
|
|
309
|
+
emit: (_payload, host) => {
|
|
310
|
+
pulledAfterCancel += 1;
|
|
311
|
+
host.cancel();
|
|
312
|
+
return null;
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const failure = expectFailure(run.outcome);
|
|
317
|
+
expect(failure.code).toBe('CANCELED');
|
|
318
|
+
expect(pulledAfterCancel).toBe(1);
|
|
319
|
+
expect(emits(run)).toHaveLength(1);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('config 不符合描述的配置项时 CONFIG_INVALID,消息含配置项名', async () => {
|
|
323
|
+
const code = rawConnector({ ...desc, config: { region: configField('string') } }, `streams: { items: defineStream('items', async function* () {}) },`);
|
|
324
|
+
const failure = expectFailure((await runGuest({ code, job: pullJob({ config: { region: 1 } }) })).outcome);
|
|
325
|
+
expect(failure.code).toBe('CONFIG_INVALID');
|
|
326
|
+
expect(failure.message).toContain('region');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('不可扩展表的记录带未声明字段 foo:照常 emit(字段由宿主丢弃),运行成功', async () => {
|
|
330
|
+
const run = await runGuest({ code: streamConnector(`yield ctx.record('item', { id: 1, foo: 'x' });`), job: pullJob(), host: { emit: () => null } });
|
|
331
|
+
expect(expectResult(run.outcome)).toMatchObject({ kind: 'pull', emittedBatches: 1 });
|
|
332
|
+
expect(emits(run)[0]?.records).toEqual([{ table: 'item', row: { id: 1, foo: 'x' } }]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('作业带生效限流时 ctx.map 的并发按它收紧', async () => {
|
|
336
|
+
const code = rawConnector({ ...desc, rateLimit: { perSecond: 10, concurrency: 8 } }, `streams: { items: defineStream('items', async function* (ctx) {
|
|
337
|
+
let active = 0; let peak = 0;
|
|
338
|
+
await ctx.map([1, 2, 3, 4, 5, 6], async () => { active += 1; peak = Math.max(peak, active); await new Promise((r) => r()); await ctx.cache.get('k'); active -= 1; });
|
|
339
|
+
ctx.log.info('peak ' + peak);
|
|
340
|
+
}) },`);
|
|
341
|
+
const run = await runGuest({ code, job: pullJob({ rateLimit: { perSecond: 10, concurrency: 2 } }), host: { 'cache.get': async () => { await Bun.sleep(5); return null; } } });
|
|
342
|
+
expect(expectResult(run.outcome)).toMatchObject({ logs: [{ message: 'peak 2' }] });
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('pull 作业:emit 回复 stop', () => {
|
|
347
|
+
const STOP_DESC = descriptor({ hosts: ['a.x.com'], streams: { items: stream({ item: table({ id: 'integer' }, ['id']) }) } });
|
|
348
|
+
|
|
349
|
+
test('回复 { stop: true } 后触发 ctx.signal、不再 emit,结果 stopped: true(不是 CANCELED)', async () => {
|
|
350
|
+
const code = rawConnector(STOP_DESC, `streams: { items: defineStream('items', async function* (ctx) {
|
|
351
|
+
ctx.signal.onAbort(() => ctx.log.info('aborted ' + ctx.signal.aborted));
|
|
352
|
+
for (let page = 0; page < 10; page += 1) {
|
|
353
|
+
yield ctx.record('item', { id: page });
|
|
354
|
+
yield ctx.checkpoint({ page });
|
|
355
|
+
}
|
|
356
|
+
}) },`);
|
|
357
|
+
let emitted = 0;
|
|
358
|
+
const run = await runGuest({
|
|
359
|
+
code, job: pullJob(),
|
|
360
|
+
host: { emit: () => { emitted += 1; return { seq: emitted, duplicate: false, rejected: 0, stop: emitted === 2 }; } },
|
|
361
|
+
});
|
|
362
|
+
const result = expectResult(run.outcome);
|
|
363
|
+
expect(emits(run)).toHaveLength(2);
|
|
364
|
+
expect(result).toEqual({ kind: 'pull', emittedBatches: 2, stopped: true, logs: [{ at: expect.any(String), level: 'info', message: 'aborted true' }] });
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('最后一批的回复带 stop 时同样 stopped: true', async () => {
|
|
368
|
+
const code = rawConnector(STOP_DESC, `streams: { items: defineStream('items', async function* (ctx) { yield ctx.record('item', { id: 1 }); }) },`);
|
|
369
|
+
const run = await runGuest({ code, job: pullJob(), host: { emit: () => ({ stop: true }) } });
|
|
370
|
+
expect(expectResult(run.outcome)).toEqual({ kind: 'pull', emittedBatches: 1, stopped: true, logs: [] });
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('suggest 作业', () => {
|
|
375
|
+
const desc = descriptor({
|
|
376
|
+
hosts: ['a.x.com'],
|
|
377
|
+
streams: {
|
|
378
|
+
customers: stream({ customers: table({ id: 'string', name: 'string' }, ['id'], { extensible: true }), groups: table({ id: 'string' }, ['id']) }, { suggestColumns: true }),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
const connector = (body: string) => rawConnector(desc, `streams: { customers: defineStream('customers', async function* () {}) },
|
|
382
|
+
suggestColumns: { customers: defineSuggestColumns('customers', async (ctx, input) => { ${body} }) },`);
|
|
383
|
+
|
|
384
|
+
test('suggestColumns 的返回值就是作业结果', async () => {
|
|
385
|
+
const run = await runGuest({ code: connector(`return [{ name: 'vip_level', type: 'string', title: 'VIP 等级' }];`), job: suggestJob('customers', 'customers') });
|
|
386
|
+
expect(expectResult(run.outcome)).toEqual({ kind: 'suggest', columns: [{ name: 'vip_level', type: 'string', title: 'VIP 等级' }], logs: [] });
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('收到的 table 参数是作业里的表名;与基础列同名的建议去掉并记 warn', async () => {
|
|
390
|
+
const run = await runGuest({
|
|
391
|
+
code: connector(`return [{ name: 'name', type: 'string', title: input.table }, { name: 'credit', type: 'decimal', precision: 12, scale: 2, title: input.table }];`),
|
|
392
|
+
job: suggestJob('customers', 'customers'),
|
|
393
|
+
});
|
|
394
|
+
const result = expectResult(run.outcome);
|
|
395
|
+
expect(result).toMatchObject({ kind: 'suggest', columns: [{ name: 'credit', type: 'decimal', precision: 12, scale: 2, title: 'customers' }] });
|
|
396
|
+
expect(result.logs.map((log) => log.level)).toEqual(['warn']);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('返回非法列名或类型时 SUGGEST_INVALID', async () => {
|
|
400
|
+
for (const value of [
|
|
401
|
+
`[{ name: '_secret', type: 'string', title: 'x' }]`,
|
|
402
|
+
`[{ name: 'vip level', type: 'string', title: 'x' }]`,
|
|
403
|
+
`[{ name: 'vip', type: 'text', title: 'x' }]`,
|
|
404
|
+
`[{ name: 'vip', type: 'decimal', title: 'x' }]`,
|
|
405
|
+
`'not a list'`,
|
|
406
|
+
]) {
|
|
407
|
+
const failure = expectFailure((await runGuest({ code: connector(`return ${value};`), job: suggestJob('customers', 'customers') })).outcome);
|
|
408
|
+
expect(failure.code).toBe('SUGGEST_INVALID');
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('表不是该数据流的可扩展表、或数据流没有实现建议列时 CONNECTOR_LOAD_FAILED', async () => {
|
|
413
|
+
const code = connector('return [];');
|
|
414
|
+
expect(expectFailure((await runGuest({ code, job: suggestJob('customers', 'groups') })).outcome).code).toBe('CONNECTOR_LOAD_FAILED');
|
|
415
|
+
expect(expectFailure((await runGuest({ code, job: suggestJob('customers', 'missing') })).outcome).code).toBe('CONNECTOR_LOAD_FAILED');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('作业里不允许 emit;suggestColumns 抛 AuthError 时 AUTH_ERROR', async () => {
|
|
419
|
+
const failure = expectFailure((await runGuest({ code: connector(`throw new errors.AuthError('bad key');`), job: suggestJob('customers', 'customers') })).outcome);
|
|
420
|
+
expect(failure).toMatchObject({ code: 'AUTH_ERROR', message: 'bad key' });
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('check 作业', () => {
|
|
425
|
+
const desc = (check: boolean) => descriptor({ hosts: ['a.x.com'], check, config: { token: configField('secret') }, streams: { items: stream(ITEM) } });
|
|
426
|
+
const connector = (check: string | null) => rawConnector(desc(check !== null), `streams: { items: defineStream('items', async function* () {}) },
|
|
427
|
+
${check !== null ? `check: defineCheck(${check}),` : ''}`);
|
|
428
|
+
|
|
429
|
+
test('check 正常返回时 { kind: check, ok: true, logs },不发任何宿主调用', async () => {
|
|
430
|
+
const run = await runGuest({ code: connector(`(ctx) => { ctx.log.info('token ' + ctx.secrets.token); }`), job: checkJob({}, ['token']) });
|
|
431
|
+
expect(expectResult(run.outcome)).toEqual({ kind: 'check', ok: true, logs: [{ at: expect.any(String), level: 'info', message: 'token ontos:secret:token' }] });
|
|
432
|
+
expect(run.calls).toEqual([]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('check 抛 AuthError 时失败 AUTH_ERROR', async () => {
|
|
436
|
+
const run = await runGuest({ code: connector(`() => { throw new errors.AuthError('bad token'); }`), job: checkJob() });
|
|
437
|
+
expect(expectFailure(run.outcome)).toMatchObject({ code: 'AUTH_ERROR', message: 'bad token' });
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('没有 check 时直接成功', async () => {
|
|
441
|
+
const run = await runGuest({ code: connector(null), job: checkJob() });
|
|
442
|
+
expect(expectResult(run.outcome)).toEqual({ kind: 'check', ok: true, logs: [] });
|
|
443
|
+
});
|
|
444
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/** 连接器操作作业:run / lookup / simulate 的入参与输出校验、错误归类、ctx 的成员与加载时的 lookup 必填校验。 */
|
|
2
|
+
import { describe, expect, test } from 'bun:test';
|
|
3
|
+
import { parseChildMessage, type GuestJob, type GuestOperationResult } from '../../src/runtime';
|
|
4
|
+
import { descriptor, operation as operationDescriptor, stream, table, valueField } from '../fixtures/descriptor';
|
|
5
|
+
import { checkJob, expectFailure, expectResult, rawConnector, runGuest, type RunOptions } from './helpers';
|
|
6
|
+
|
|
7
|
+
const INPUT = { sku: valueField('string'), qty: valueField('integer'), note: valueField('string', { optional: true }) };
|
|
8
|
+
const OUTPUT = { externalId: valueField('string') };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 一个有数据流(占位)与若干操作的连接器;`operations` 是操作名 → 实现对象源码,
|
|
12
|
+
* `idempotency` 给出各操作在描述里的幂等方式(缺省 supported)。
|
|
13
|
+
*/
|
|
14
|
+
function operationConnector(operations: Record<string, string>, idempotency: Record<string, 'supported' | 'unsupported'> = {}): string {
|
|
15
|
+
const desc = descriptor({
|
|
16
|
+
hosts: ['erp.example.com'],
|
|
17
|
+
streams: { orders: stream({ orders: table({ id: 'string' }, ['id']) }) },
|
|
18
|
+
operations: Object.fromEntries(Object.keys(operations).map((name) => [name, operationDescriptor(INPUT, OUTPUT, { risk: 'high', idempotency: idempotency[name] ?? 'supported' })])),
|
|
19
|
+
});
|
|
20
|
+
const impls = Object.entries(operations).map(([name, body]) => `${name}: defineOperation('${name}', { ${body} })`).join(', ');
|
|
21
|
+
return rawConnector(desc, `streams: { orders: defineStream('orders', async function* () {}) }, operations: { ${impls} },`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function operation(body: string, extra = ''): Record<string, string> {
|
|
25
|
+
return { create_order: `${body}\n${extra}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function operationJob(overrides: Partial<Extract<GuestJob, { kind: 'operation' }>> = {}): GuestJob {
|
|
29
|
+
return {
|
|
30
|
+
kind: 'operation', phase: 'run', config: {}, secretFields: [], operation: 'create_order',
|
|
31
|
+
params: { sku: 'A-1', qty: 2 }, idempotencyKey: 'key-1', ...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runOperation(code: string, job: GuestJob, host: RunOptions['host'] = {}): Promise<{ result: GuestOperationResult; methods: string[] }> {
|
|
36
|
+
const run = await runGuest({ code, job, host });
|
|
37
|
+
const result = expectResult(run.outcome);
|
|
38
|
+
if (result.kind !== 'operation') throw new Error(`expected operation result, got ${result.kind}`);
|
|
39
|
+
return { result, methods: run.calls.map((call) => call.method) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('run', () => {
|
|
43
|
+
test('入参缺非 optional 字段时 INPUT_INVALID,run 没被调用', async () => {
|
|
44
|
+
const code = operationConnector(operation(`async run(ctx) { ctx.log.info('called'); await ctx.http.post('/orders'); return { externalId: 'x' }; },`));
|
|
45
|
+
const { result, methods } = await runOperation(code, operationJob({ params: { sku: 'A-1' } }));
|
|
46
|
+
expect(result).toMatchObject({ kind: 'operation', phase: 'run', ok: false, code: 'INPUT_INVALID' });
|
|
47
|
+
expect(methods).toEqual([]);
|
|
48
|
+
expect(result.logs).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('成功:idempotencyKey 透传给 run,请求经宿主,输出按声明校验后原样返回', async () => {
|
|
52
|
+
const code = operationConnector(operation(`async run(ctx, params, meta) {
|
|
53
|
+
const res = await ctx.http.post('/orders', { headers: { 'Idempotency-Key': meta.idempotencyKey }, body: params });
|
|
54
|
+
return { externalId: res.data.id };
|
|
55
|
+
},`));
|
|
56
|
+
const seen: unknown[] = [];
|
|
57
|
+
const { result, methods } = await runOperation(code, operationJob(), {
|
|
58
|
+
http: (payload) => {
|
|
59
|
+
seen.push(payload);
|
|
60
|
+
return { status: 201, url: 'https://erp.example.com/orders', headers: { 'content-type': 'application/json' }, body: '{"id":"SO-1"}', bodyEncoding: 'utf8' };
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(result).toEqual({ kind: 'operation', phase: 'run', ok: true, output: { externalId: 'SO-1' }, logs: [] });
|
|
64
|
+
expect(methods).toEqual(['http']);
|
|
65
|
+
expect(seen[0]).toMatchObject({ method: 'POST', url: 'https://erp.example.com/orders', headers: { 'idempotency-key': 'key-1' } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('返回值缺 output 声明的字段时 OUTPUT_INVALID', async () => {
|
|
69
|
+
const code = operationConnector(operation(`run() { return { other: 1 }; },`));
|
|
70
|
+
const { result } = await runOperation(code, operationJob());
|
|
71
|
+
expect(result).toMatchObject({ ok: false, code: 'OUTPUT_INVALID' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('RejectedError 得 REJECTED 并带外部业务码;IndeterminateError 得 INDETERMINATE;AuthError 得 AUTH_ERROR', async () => {
|
|
75
|
+
const cases: Array<[string, Record<string, unknown>]> = [
|
|
76
|
+
[`throw new errors.RejectedError('库存不足', { code: 'OUT_OF_STOCK' });`, { code: 'REJECTED', external: { code: 'OUT_OF_STOCK', message: '库存不足' } }],
|
|
77
|
+
[`throw new errors.IndeterminateError('connection dropped');`, { code: 'INDETERMINATE', message: 'connection dropped' }],
|
|
78
|
+
[`throw new errors.AuthError('token expired');`, { code: 'AUTH_ERROR', message: 'token expired' }],
|
|
79
|
+
[`throw new errors.RateLimited(10);`, { code: 'TRANSIENT_ERROR' }],
|
|
80
|
+
[`throw new Error('boom');`, { code: 'FATAL_ERROR', message: 'boom' }],
|
|
81
|
+
];
|
|
82
|
+
for (const [statement, expected] of cases) {
|
|
83
|
+
const { result } = await runOperation(operationConnector(operation(`run() { ${statement} },`)), operationJob());
|
|
84
|
+
expect(result).toMatchObject({ kind: 'operation', phase: 'run', ok: false, ...expected });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('ctx 没有 record 与 db,调用 ctx.db 得 TypeError', async () => {
|
|
89
|
+
const code = operationConnector(operation(`run(ctx) {
|
|
90
|
+
let name = 'none';
|
|
91
|
+
try { ctx.db.query('select 1'); } catch (e) { name = e.name; }
|
|
92
|
+
return { externalId: [name, 'record' in ctx, 'db' in ctx, typeof ctx.http.post, typeof ctx.crypto.hash, typeof ctx.cache.get].join(',') };
|
|
93
|
+
},`));
|
|
94
|
+
const { result } = await runOperation(code, operationJob());
|
|
95
|
+
expect(result).toMatchObject({ ok: true, output: { externalId: 'TypeError,false,false,function,function,function' } });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('操作名不在描述里时 CONNECTOR_LOAD_FAILED', async () => {
|
|
99
|
+
const code = operationConnector(operation(`run() { return { externalId: 'x' }; },`));
|
|
100
|
+
const { result } = await runOperation(code, operationJob({ operation: 'missing' }));
|
|
101
|
+
expect(result).toMatchObject({ ok: false, code: 'CONNECTOR_LOAD_FAILED' });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('lookup', () => {
|
|
106
|
+
const lookupConnector = (lookup: string) => operationConnector(operation(`run() { return { externalId: 'x' }; },`, `lookup: ${lookup},`));
|
|
107
|
+
|
|
108
|
+
test('lookup 抛错按 unknown', async () => {
|
|
109
|
+
const { result } = await runOperation(lookupConnector(`() => { throw new Error('erp down'); }`), operationJob({ phase: 'lookup' }));
|
|
110
|
+
expect(result).toMatchObject({ kind: 'operation', phase: 'lookup', ok: true, lookup: { status: 'unknown' } });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('applied 的输出按声明校验:合格时带出,不合格按 unknown;not_applied 原样', async () => {
|
|
114
|
+
const applied = await runOperation(
|
|
115
|
+
lookupConnector(`(ctx, input) => ({ status: 'applied', output: { externalId: input.idempotencyKey + ':' + input.params.sku } })`),
|
|
116
|
+
operationJob({ phase: 'lookup' }),
|
|
117
|
+
);
|
|
118
|
+
expect(applied.result).toMatchObject({ ok: true, lookup: { status: 'applied', output: { externalId: 'key-1:A-1' } } });
|
|
119
|
+
const invalid = await runOperation(lookupConnector(`() => ({ status: 'applied', output: { wrong: 1 } })`), operationJob({ phase: 'lookup' }));
|
|
120
|
+
expect(invalid.result).toMatchObject({ ok: true, lookup: { status: 'unknown' } });
|
|
121
|
+
const notApplied = await runOperation(lookupConnector(`async () => ({ status: 'not_applied' })`), operationJob({ phase: 'lookup' }));
|
|
122
|
+
expect(notApplied.result).toMatchObject({ ok: true, lookup: { status: 'not_applied' } });
|
|
123
|
+
const odd = await runOperation(lookupConnector(`() => ({ status: 'maybe' })`), operationJob({ phase: 'lookup' }));
|
|
124
|
+
expect(odd.result).toMatchObject({ ok: true, lookup: { status: 'unknown' } });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('没有实现 lookup 的 supported 操作按 unknown', async () => {
|
|
128
|
+
const code = operationConnector(operation(`run() { return { externalId: 'x' }; },`));
|
|
129
|
+
const { result } = await runOperation(code, operationJob({ phase: 'lookup' }));
|
|
130
|
+
expect(result).toMatchObject({ ok: true, lookup: { status: 'unknown', notImplemented: true } });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('simulate', () => {
|
|
135
|
+
test('simulate 只收 params,不发任何宿主调用', async () => {
|
|
136
|
+
const code = operationConnector(operation(`run() { return { externalId: 'real' }; },`,
|
|
137
|
+
`simulate(params) { return { externalId: 'sim-' + params.sku + '-' + arguments.length }; },`));
|
|
138
|
+
const { result, methods } = await runOperation(code, operationJob({ phase: 'simulate' }));
|
|
139
|
+
expect(result).toEqual({ kind: 'operation', phase: 'simulate', ok: true, output: { externalId: 'sim-A-1-1' }, logs: [] });
|
|
140
|
+
expect(methods).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('没实现 simulate 时 FATAL_ERROR(宿主据此回退缺省结果)', async () => {
|
|
144
|
+
const code = operationConnector(operation(`run() { return { externalId: 'real' }; },`));
|
|
145
|
+
const { result } = await runOperation(code, operationJob({ phase: 'simulate' }));
|
|
146
|
+
expect(result).toMatchObject({ phase: 'simulate', ok: false, code: 'FATAL_ERROR' });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('simulate 作业里的任何宿主调用在宿主侧都不合法;其余阶段不允许 db 与 emit', () => {
|
|
150
|
+
const simulateJob = operationJob({ phase: 'simulate' });
|
|
151
|
+
for (const method of ['http', 'secret.reveal', 'cache.get']) {
|
|
152
|
+
expect(parseChildMessage({ type: 'call', id: 1, method, payload: { key: 'k' } }, simulateJob)).toBeNull();
|
|
153
|
+
}
|
|
154
|
+
const runJob = operationJob();
|
|
155
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'db.query', payload: { sql: 'select 1', params: [] } }, runJob)).toBeNull();
|
|
156
|
+
expect(parseChildMessage({ type: 'call', id: 1, method: 'cache.get', payload: { key: 'k' } }, runJob)).toMatchObject({ type: 'call', method: 'cache.get' });
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('加载', () => {
|
|
161
|
+
test('unsupported 操作没有 lookup 时加载失败,失败码 CONNECTOR_LOOKUP_REQUIRED(其他作业同样)', async () => {
|
|
162
|
+
const code = operationConnector({ update_order: `run() { return { externalId: 'x' }; },` }, { update_order: 'unsupported' });
|
|
163
|
+
const failure = expectFailure((await runGuest({ code, job: checkJob() })).outcome);
|
|
164
|
+
expect(failure.code).toBe('CONNECTOR_LOOKUP_REQUIRED');
|
|
165
|
+
expect(failure.message).toContain('update_order');
|
|
166
|
+
const operationFailure = expectFailure((await runGuest({ code, job: operationJob({ operation: 'update_order' }) })).outcome);
|
|
167
|
+
expect(operationFailure.code).toBe('CONNECTOR_LOOKUP_REQUIRED');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('unsupported 操作实现了 lookup 时正常执行', async () => {
|
|
171
|
+
const code = operationConnector({ update_order: `run() { return { externalId: 'x' }; }, lookup() { return { status: 'not_applied' }; },` }, { update_order: 'unsupported' });
|
|
172
|
+
const { result } = await runOperation(code, operationJob({ operation: 'update_order' }));
|
|
173
|
+
expect(result).toMatchObject({ ok: true, output: { externalId: 'x' } });
|
|
174
|
+
});
|
|
175
|
+
});
|