@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.
Files changed (185) hide show
  1. package/README.md +158 -139
  2. package/package.json +19 -28
  3. package/scripts/build.ts +188 -0
  4. package/src/define.ts +267 -0
  5. package/src/descriptor.ts +116 -0
  6. package/src/errors.ts +105 -0
  7. package/src/index.ts +108 -0
  8. package/src/internal/location.ts +23 -0
  9. package/src/runtime/guest/bridge.ts +238 -0
  10. package/src/runtime/guest/cache.ts +43 -0
  11. package/src/runtime/guest/check.ts +17 -0
  12. package/src/runtime/guest/crypto.ts +166 -0
  13. package/src/runtime/guest/ctx.ts +157 -0
  14. package/src/runtime/guest/db.ts +59 -0
  15. package/src/runtime/guest/failure.ts +69 -0
  16. package/src/runtime/guest/http.ts +194 -0
  17. package/src/runtime/guest/index.ts +102 -0
  18. package/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
  19. package/src/runtime/guest/loader.ts +210 -0
  20. package/src/runtime/guest/log.ts +47 -0
  21. package/src/runtime/guest/map.ts +45 -0
  22. package/src/runtime/guest/operation.ts +193 -0
  23. package/src/runtime/guest/paginate.ts +169 -0
  24. package/src/runtime/guest/pull.ts +177 -0
  25. package/src/runtime/guest/push.ts +92 -0
  26. package/src/runtime/guest/signal.ts +14 -0
  27. package/src/runtime/guest/suggest.ts +51 -0
  28. package/src/runtime/guest/time.ts +137 -0
  29. package/src/runtime/guest-runtime.ts +75 -0
  30. package/src/runtime/index.ts +53 -0
  31. package/src/runtime/protocol.ts +557 -0
  32. package/src/runtime/session.ts +216 -0
  33. package/src/testing/child.ts +21 -0
  34. package/src/testing/compliance.ts +343 -0
  35. package/src/testing/engine.ts +445 -0
  36. package/src/testing/fixtures.ts +591 -0
  37. package/src/testing/gateway.ts +482 -0
  38. package/src/testing/import-guard.ts +221 -0
  39. package/src/testing/index.ts +43 -0
  40. package/src/testing/local-db.ts +226 -0
  41. package/src/testing/mock-external-system.ts +249 -0
  42. package/src/testing/pinned-http.ts +144 -0
  43. package/src/testing/recorder.ts +140 -0
  44. package/src/testing/redaction.ts +94 -0
  45. package/src/testing/replay.ts +437 -0
  46. package/src/testing/run-tests.ts +115 -0
  47. package/src/testing/schema.ts +70 -0
  48. package/src/testing/source-loader.ts +374 -0
  49. package/src/testing/types.ts +78 -0
  50. package/src/types.ts +329 -0
  51. package/src/validate.ts +267 -0
  52. package/test/build.test.ts +159 -0
  53. package/test/define.test-d.ts +182 -0
  54. package/test/fixtures/acceptance.ts +156 -0
  55. package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
  56. package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
  57. package/test/fixtures/connectors/http-pull/package.json +5 -0
  58. package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
  59. package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
  60. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
  61. package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
  62. package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
  63. package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
  64. package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
  65. package/test/fixtures/connectors/pg-pull/package.json +5 -0
  66. package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
  67. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
  68. package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
  69. package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
  70. package/test/fixtures/descriptor.ts +54 -0
  71. package/test/fixtures/kingdee-example.ts +82 -0
  72. package/test/fixtures/record-acceptance.ts +32 -0
  73. package/test/runtime/ctx.test.ts +434 -0
  74. package/test/runtime/helpers.ts +157 -0
  75. package/test/runtime/jobs.test.ts +444 -0
  76. package/test/runtime/operation.test.ts +175 -0
  77. package/test/runtime/session.test.ts +229 -0
  78. package/test/testing/acceptance-fixtures.test.ts +69 -0
  79. package/test/testing/compliance.test.ts +247 -0
  80. package/test/testing/engine.test.ts +283 -0
  81. package/test/testing/fixtures.test.ts +221 -0
  82. package/test/testing/gateway.test.ts +337 -0
  83. package/test/testing/import-guard.test.ts +45 -0
  84. package/test/testing/mock-external-system.test.ts +144 -0
  85. package/test/testing/operation-fixtures.test.ts +160 -0
  86. package/test/testing/recorder.test.ts +163 -0
  87. package/test/testing/redaction.test.ts +54 -0
  88. package/test/testing/replay.test.ts +207 -0
  89. package/test/testing/run-tests.test.ts +137 -0
  90. package/test/testing/source-loader.test.ts +220 -0
  91. package/test/validate-config.test.ts +104 -0
  92. package/tsconfig.build.json +23 -0
  93. package/tsconfig.json +19 -0
  94. package/cjs/index.cjs +0 -1015
  95. package/cjs/runtime.cjs +0 -752
  96. package/cjs/testing.cjs +0 -3591
  97. package/errors.d.ts +0 -17
  98. package/errors.js +0 -53
  99. package/guest.cjs +0 -2659
  100. package/index.d.ts +0 -13
  101. package/index.js +0 -7
  102. package/internal/location.d.ts +0 -6
  103. package/internal/location.js +0 -8
  104. package/presentation.d.ts +0 -38
  105. package/presentation.js +0 -268
  106. package/runtime/guest/bridge.d.ts +0 -53
  107. package/runtime/guest/bridge.js +0 -187
  108. package/runtime/guest/cache.d.ts +0 -3
  109. package/runtime/guest/cache.js +0 -42
  110. package/runtime/guest/crypto.d.ts +0 -13
  111. package/runtime/guest/crypto.js +0 -177
  112. package/runtime/guest/ctx.d.ts +0 -30
  113. package/runtime/guest/ctx.js +0 -124
  114. package/runtime/guest/db.d.ts +0 -6
  115. package/runtime/guest/db.js +0 -59
  116. package/runtime/guest/discover.d.ts +0 -29
  117. package/runtime/guest/discover.js +0 -51
  118. package/runtime/guest/failure.d.ts +0 -24
  119. package/runtime/guest/failure.js +0 -63
  120. package/runtime/guest/http.d.ts +0 -4
  121. package/runtime/guest/http.js +0 -204
  122. package/runtime/guest/index.d.ts +0 -12
  123. package/runtime/guest/index.js +0 -89
  124. package/runtime/guest/limits.js +0 -12
  125. package/runtime/guest/loader.d.ts +0 -15
  126. package/runtime/guest/loader.js +0 -42
  127. package/runtime/guest/log.d.ts +0 -10
  128. package/runtime/guest/log.js +0 -36
  129. package/runtime/guest/map.d.ts +0 -4
  130. package/runtime/guest/map.js +0 -45
  131. package/runtime/guest/paginate.d.ts +0 -6
  132. package/runtime/guest/paginate.js +0 -180
  133. package/runtime/guest/pull.d.ts +0 -12
  134. package/runtime/guest/pull.js +0 -150
  135. package/runtime/guest/push.d.ts +0 -18
  136. package/runtime/guest/push.js +0 -80
  137. package/runtime/guest/signal.d.ts +0 -3
  138. package/runtime/guest/signal.js +0 -12
  139. package/runtime/guest/time.d.ts +0 -5
  140. package/runtime/guest/time.js +0 -127
  141. package/runtime/guest-runtime.d.ts +0 -7
  142. package/runtime/guest-runtime.js +0 -51
  143. package/runtime/index.d.ts +0 -18
  144. package/runtime/index.js +0 -3
  145. package/runtime/protocol.d.ts +0 -276
  146. package/runtime/protocol.js +0 -409
  147. package/runtime/session.d.ts +0 -12
  148. package/runtime/session.js +0 -191
  149. package/t.d.ts +0 -18
  150. package/t.js +0 -26
  151. package/testing/child.cjs +0 -350
  152. package/testing/child.d.ts +0 -1
  153. package/testing/child.js +0 -12
  154. package/testing/compliance.d.ts +0 -41
  155. package/testing/compliance.js +0 -291
  156. package/testing/engine.d.ts +0 -66
  157. package/testing/engine.js +0 -339
  158. package/testing/fixtures.d.ts +0 -145
  159. package/testing/fixtures.js +0 -407
  160. package/testing/gateway.d.ts +0 -65
  161. package/testing/gateway.js +0 -402
  162. package/testing/import-guard.d.ts +0 -57
  163. package/testing/import-guard.js +0 -187
  164. package/testing/index.d.ts +0 -33
  165. package/testing/index.js +0 -10
  166. package/testing/local-db.d.ts +0 -27
  167. package/testing/local-db.js +0 -181
  168. package/testing/pinned-http.d.ts +0 -14
  169. package/testing/pinned-http.js +0 -125
  170. package/testing/recorder.d.ts +0 -15
  171. package/testing/recorder.js +0 -116
  172. package/testing/redaction.d.ts +0 -28
  173. package/testing/redaction.js +0 -67
  174. package/testing/replay.d.ts +0 -50
  175. package/testing/replay.js +0 -313
  176. package/testing/schema.d.ts +0 -13
  177. package/testing/schema.js +0 -65
  178. package/testing/source-loader.d.ts +0 -19
  179. package/testing/source-loader.js +0 -238
  180. package/testing/types.d.ts +0 -79
  181. package/testing/types.js +0 -18
  182. package/types.d.ts +0 -302
  183. package/types.js +0 -13
  184. package/validate.d.ts +0 -78
  185. package/validate.js +0 -597
@@ -0,0 +1,434 @@
1
+ /** `ctx` 的各成员:在 pull 作业里跑数据流,用假宿主回应宿主调用,从 `emit` 的记录里读结果。 */
2
+ import { describe, expect, test } from 'bun:test';
3
+ import type { ConfigField, ConnectorDescriptor } from '../../src/index';
4
+ import type { EgressHttpRequest, EgressHttpResponse, GuestPullBatch } from '../../src/runtime';
5
+ import { configField, descriptor, stream as streamDescriptor, table } from '../fixtures/descriptor';
6
+ import { checkJob, expectFailure, expectResult, HostReject, pullJob, rawConnector, runGuest, type HostHandler, type RunResult } from './helpers';
7
+
8
+ function itemsDescriptor(options: { config?: Record<string, ConfigField>; extra?: Partial<ConnectorDescriptor> } = {}): ConnectorDescriptor {
9
+ return descriptor({
10
+ config: options.config ?? {}, hosts: ['api.x.com', '*.x.com'],
11
+ streams: { items: streamDescriptor({ item: table({ id: 'string' }, ['id']) }, { syncModes: ['full'] }) },
12
+ ...options.extra,
13
+ });
14
+ }
15
+
16
+ /** 一个只有数据流 `items` 的连接器;`stream` 是生成器函数体(可用 ctx、input)。 */
17
+ function streamConnector(stream: string, options: { config?: Record<string, ConfigField>; extra?: Partial<ConnectorDescriptor>; header?: string } = {}): string {
18
+ return rawConnector(itemsDescriptor(options), `streams: { items: defineStream('items', async function* (ctx, input) { ${stream} }) },`, options.header ?? '');
19
+ }
20
+
21
+ /** 带 `check` 的连接器:`body` 是 check 的函数体(可用 ctx)。 */
22
+ function checkConnector(body: string): string {
23
+ return rawConnector(itemsDescriptor({ extra: { hosts: ['api.x.com'], check: true } }),
24
+ `streams: { items: defineStream('items', async function* () {}) }, check: defineCheck(async (ctx) => { ${body} }),`);
25
+ }
26
+
27
+ function rows(run: RunResult): Array<Record<string, unknown>> {
28
+ return run.calls.filter((entry) => entry.method === 'emit').flatMap((entry) => (entry.payload as GuestPullBatch).records.map((record) => record.row));
29
+ }
30
+
31
+ function httpResponse(status: number, body: unknown, headers: Record<string, string> = {}, url = 'https://api.x.com/'): EgressHttpResponse {
32
+ const text = typeof body === 'string' ? body : JSON.stringify(body);
33
+ return { status, url, headers: { 'content-type': typeof body === 'string' ? 'text/plain' : 'application/json', ...headers }, body: text, bodyEncoding: 'utf8' };
34
+ }
35
+
36
+ /** 依次返回给定的回复;值为 HostReject 时回错误。 */
37
+ function sequence(...replies: Array<EgressHttpResponse | HostReject>): HostHandler {
38
+ let index = 0;
39
+ return () => {
40
+ const reply = replies[Math.min(index, replies.length - 1)];
41
+ index += 1;
42
+ if (reply instanceof HostReject) throw reply;
43
+ return reply;
44
+ };
45
+ }
46
+
47
+ const httpCalls = (run: RunResult) => run.calls.filter((entry) => entry.method === 'http').map((entry) => entry.payload as EgressHttpRequest);
48
+
49
+ describe('ctx.secrets', () => {
50
+ test('普通字段是句柄;exposeToScript 字段经一次 secret.reveal 取明文并缓存', async () => {
51
+ const code = streamConnector(
52
+ `yield ctx.record('item', { key: ctx.secrets.appKey, secret: ctx.secrets.appSecret, again: ctx.secrets.appSecret, missing: ctx.secrets.other === undefined });`,
53
+ { config: { appKey: configField('secret'), appSecret: configField('secret', { exposeToScript: true }), other: configField('secret') } },
54
+ );
55
+ const run = await runGuest({
56
+ code, job: pullJob({ secretFields: ['appKey', 'appSecret'] }),
57
+ host: { 'secret.reveal': (payload) => ({ value: `plain-${(payload as { field: string }).field}` }) },
58
+ });
59
+ expectResult(run.outcome);
60
+ expect(rows(run)).toEqual([{ key: 'ontos:secret:appKey', secret: 'plain-appSecret', again: 'plain-appSecret', missing: true }]);
61
+ expect(run.calls.filter((entry) => entry.method === 'secret.reveal')).toEqual([{ method: 'secret.reveal', payload: { field: 'appSecret' } }]);
62
+ });
63
+
64
+ test('ctx.files 访问即抛 NOT_AVAILABLE', async () => {
65
+ const code = streamConnector(`let code; try { ctx.files; } catch (e) { code = e.code; } yield ctx.record('item', { code });`);
66
+ const run = await runGuest({ code, job: pullJob() });
67
+ expect(rows(run)).toEqual([{ code: 'NOT_AVAILABLE' }]);
68
+ });
69
+ });
70
+
71
+ describe('ctx.http', () => {
72
+ test('相对路径拼在 auth.baseUrl 上,query 值转成字符串,JSON 响应解析成对象', async () => {
73
+ const code = streamConnector(`
74
+ const res = await ctx.http.get('/v2/orders', { query: { page: 1, tags: ['a', 2], skip: undefined }, headers: { 'X-Trace': 'abc' }, auth: { baseUrl: 'https://api.x.com', headers: { 'app-token': 't1' } } });
75
+ const posted = await ctx.http.post('https://api.x.com/v2/orders', { body: { a: 1 } });
76
+ yield ctx.record('item', { data: res.data, text: res.text, status: res.status, postedType: typeof posted.data });`);
77
+ const run = await runGuest({
78
+ code, job: pullJob(),
79
+ host: { http: (payload) => ((payload as EgressHttpRequest).method === 'GET' ? httpResponse(200, { orders: [1, 2] }) : httpResponse(201, 'created')) },
80
+ });
81
+ expectResult(run.outcome);
82
+ const [get, post] = httpCalls(run);
83
+ expect(get).toEqual({
84
+ method: 'GET', url: 'https://api.x.com/v2/orders', headers: { 'app-token': 't1', 'x-trace': 'abc' },
85
+ query: { page: '1', tags: ['a', '2'] }, body: null, bodyEncoding: 'utf8',
86
+ });
87
+ expect(post).toMatchObject({ method: 'POST', body: '{"a":1}', headers: { 'content-type': 'application/json' } });
88
+ expect(rows(run)).toEqual([{ data: { orders: [1, 2] }, text: '{"orders":[1,2]}', status: 200, postedType: 'string' }]);
89
+ });
90
+
91
+ test('没有 baseUrl 时相对路径拼在 hosts 里第一个确定的主机上', async () => {
92
+ const code = streamConnector(`await ctx.http.get('orders'); yield ctx.record('item', {});`);
93
+ const run = await runGuest({ code, job: pullJob(), host: { http: () => httpResponse(200, {}) } });
94
+ expectResult(run.outcome);
95
+ expect(httpCalls(run)[0]?.url).toBe('https://api.x.com/orders');
96
+ });
97
+
98
+ test('网关限流(retryAfterMs: 50)等待后重发', async () => {
99
+ const code = streamConnector(`const res = await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', { status: res.status });`);
100
+ const limited = new HostReject({ code: 'EGRESS_RATE_LIMITED', message: 'slow down', retryAfterMs: 50 });
101
+ const started = Date.now();
102
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(limited, limited, httpResponse(200, {})) } });
103
+ expectResult(run.outcome);
104
+ expect(httpCalls(run)).toHaveLength(3);
105
+ expect(Date.now() - started).toBeGreaterThanOrEqual(90);
106
+ });
107
+
108
+ test('网关限流超过 5 次重试后失败', async () => {
109
+ const code = streamConnector(`await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', {});`);
110
+ const limited = new HostReject({ code: 'EGRESS_RATE_LIMITED', message: 'slow down', retryAfterMs: 5 });
111
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(limited) } });
112
+ const failure = expectFailure(run.outcome);
113
+ expect(failure.code).toBe('TRANSIENT_ERROR');
114
+ expect(failure.message).toContain('rate limited after 5 retries');
115
+ expect(httpCalls(run)).toHaveLength(6);
116
+ });
117
+
118
+ test('上游 429 按 Retry-After 秒数等待后重发', async () => {
119
+ const code = streamConnector(`const res = await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', { status: res.status });`);
120
+ const started = Date.now();
121
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(httpResponse(429, 'busy', { 'retry-after': '1' }), httpResponse(200, {})) } });
122
+ expect(rows(run)).toEqual([{ status: 200 }]);
123
+ expect(Date.now() - started).toBeGreaterThanOrEqual(950);
124
+ });
125
+
126
+ test('上游 502/503/504 指数退避重试 3 次后抛 TransientError', async () => {
127
+ const code = streamConnector(`await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', {});`);
128
+ const started = Date.now();
129
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(httpResponse(502, 'bad'), httpResponse(503, 'bad'), httpResponse(504, 'bad'), httpResponse(502, 'bad')) } });
130
+ const elapsed = Date.now() - started;
131
+ const failure = expectFailure(run.outcome);
132
+ expect(failure.code).toBe('TRANSIENT_ERROR');
133
+ expect(failure.message).toContain('returned 502 after 3 retries');
134
+ expect(httpCalls(run)).toHaveLength(4);
135
+ // 200 + 400 + 800
136
+ expect(elapsed).toBeGreaterThanOrEqual(1350);
137
+ });
138
+
139
+ test('POST / PATCH 遇 502 与网关超时缺省不重试;PUT / DELETE 照常重试', async () => {
140
+ for (const method of ['post', 'patch']) {
141
+ for (const failureReply of [httpResponse(502, 'bad'), new HostReject({ code: 'EGRESS_TIMEOUT', message: 'timed out' })]) {
142
+ const code = streamConnector(`await ctx.http.${method}('https://api.x.com/a', { body: { n: 1 } }); yield ctx.record('item', {});`);
143
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(failureReply, httpResponse(200, {})) } });
144
+ const failure = expectFailure(run.outcome);
145
+ expect(failure.code).toBe('TRANSIENT_ERROR');
146
+ expect(failure.message).toContain('after 0 retries');
147
+ expect(httpCalls(run)).toHaveLength(1);
148
+ }
149
+ }
150
+ for (const method of ['put', 'delete']) {
151
+ const code = streamConnector(`const res = await ctx.http.${method}('https://api.x.com/a'); yield ctx.record('item', { status: res.status });`);
152
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(httpResponse(503, 'bad'), httpResponse(200, {})) } });
153
+ expect(rows(run)).toEqual([{ status: 200 }]);
154
+ expect(httpCalls(run)).toHaveLength(2);
155
+ }
156
+ });
157
+
158
+ test('retry: true 让 POST 也重试;retry: false 让 GET 也不重试;限流与 429 不受 retry 影响', async () => {
159
+ const post = streamConnector(`const res = await ctx.http.post('https://api.x.com/a', { body: {}, retry: true }); yield ctx.record('item', { status: res.status });`);
160
+ const postRun = await runGuest({ code: post, job: pullJob(), host: { http: sequence(httpResponse(502, 'bad'), httpResponse(200, {})) } });
161
+ expect(rows(postRun)).toEqual([{ status: 200 }]);
162
+ expect(httpCalls(postRun)).toHaveLength(2);
163
+
164
+ const get = streamConnector(`await ctx.http.get('https://api.x.com/a', { retry: false }); yield ctx.record('item', {});`);
165
+ const getRun = await runGuest({ code: get, job: pullJob(), host: { http: sequence(httpResponse(504, 'bad'), httpResponse(200, {})) } });
166
+ expect(expectFailure(getRun.outcome).code).toBe('TRANSIENT_ERROR');
167
+ expect(httpCalls(getRun)).toHaveLength(1);
168
+
169
+ const limitedPost = streamConnector(`const res = await ctx.http.post('https://api.x.com/a', { body: {}, retry: false }); yield ctx.record('item', { status: res.status });`);
170
+ const limited = new HostReject({ code: 'EGRESS_RATE_LIMITED', message: 'slow down', retryAfterMs: 5 });
171
+ const limitedRun = await runGuest({ code: limitedPost, job: pullJob(), host: { http: sequence(limited, httpResponse(429, 'busy', { 'retry-after': '0' }), httpResponse(200, {})) } });
172
+ expect(rows(limitedRun)).toEqual([{ status: 200 }]);
173
+ expect(httpCalls(limitedRun)).toHaveLength(3);
174
+ });
175
+
176
+ test('网关超时同样退避重试,恢复后照常返回', async () => {
177
+ const code = streamConnector(`const res = await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', { status: res.status });`);
178
+ const timeout = new HostReject({ code: 'EGRESS_TIMEOUT', message: 'timed out' });
179
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(timeout, httpResponse(200, {})) } });
180
+ expect(rows(run)).toEqual([{ status: 200 }]);
181
+ expect(httpCalls(run)).toHaveLength(2);
182
+ });
183
+
184
+ test('上游 401 不重试,抛 AuthError;其他宿主错误按 code 失败', async () => {
185
+ const code = streamConnector(`await ctx.http.get('https://api.x.com/a'); yield ctx.record('item', {});`);
186
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(httpResponse(401, 'no')) } });
187
+ expect(expectFailure(run.outcome).code).toBe('AUTH_ERROR');
188
+ expect(httpCalls(run)).toHaveLength(1);
189
+
190
+ const denied = await runGuest({ code, job: pullJob(), host: { http: sequence(new HostReject({ code: 'EGRESS_HOST_DENIED', message: 'host evil.com is not allowed', status: 403 })) } });
191
+ expect(expectFailure(denied.outcome)).toMatchObject({ code: 'FATAL_ERROR', message: 'EGRESS_HOST_DENIED: host evil.com is not allowed' });
192
+ });
193
+
194
+ test('raw: true 不按状态码抛错、不解析 JSON', async () => {
195
+ const code = streamConnector(`const res = await ctx.http.get('https://api.x.com/a', { raw: true }); yield ctx.record('item', { status: res.status, data: res.data });`);
196
+ const run = await runGuest({ code, job: pullJob(), host: { http: sequence(httpResponse(404, { error: 'x' })) } });
197
+ expect(rows(run)).toEqual([{ status: 404, data: '{"error":"x"}' }]);
198
+ });
199
+ });
200
+
201
+ describe('ctx.paginate', () => {
202
+ test('page:共 3 页时依次产出 3 页,maxOf 取该页最大值', async () => {
203
+ const code = streamConnector(`
204
+ for await (const page of ctx.paginate.page({
205
+ firstPage: 1,
206
+ request: (n) => ctx.http.get('https://api.x.com/p', { query: { page: n } }),
207
+ rows: (res) => res.data.rows,
208
+ totalPages: (res) => res.data.total,
209
+ })) yield ctx.record('item', { ids: page.rows.map((r) => r.id), max: page.maxOf((r) => r.at) });`);
210
+ const pages: Record<string, unknown> = {
211
+ 1: { rows: [{ id: 1, at: '2026-01-02' }, { id: 2, at: '2026-01-05' }], total: 3 },
212
+ 2: { rows: [{ id: 3, at: '2026-02-01' }], total: 3 },
213
+ 3: { rows: [{ id: 4, at: '2026-03-09' }, { id: 5, at: '2026-03-01' }], total: 3 },
214
+ };
215
+ const run = await runGuest({ code, job: pullJob(), host: { http: (payload) => httpResponse(200, pages[String((payload as EgressHttpRequest).query.page)]) } });
216
+ expect(rows(run)).toEqual([{ ids: [1, 2], max: '2026-01-05' }, { ids: [3], max: '2026-02-01' }, { ids: [4, 5], max: '2026-03-09' }]);
217
+ expect(httpCalls(run)).toHaveLength(3);
218
+ });
219
+
220
+ test('cursor / offset / link 各给 2 页数据,没有下一页时结束', async () => {
221
+ const code = streamConnector(`
222
+ const out = { cursor: [], offset: [], link: [] };
223
+ for await (const page of ctx.paginate.cursor({
224
+ request: (cursor) => ctx.http.get('https://api.x.com/c', { query: { cursor } }),
225
+ rows: (res) => res.data.items, nextCursor: (res) => res.data.next,
226
+ })) out.cursor.push(page.rows);
227
+ for await (const page of ctx.paginate.offset({
228
+ limit: 2, request: (offset, limit) => ctx.http.get('https://api.x.com/o', { query: { offset, limit } }), rows: (res) => res.data.items,
229
+ })) out.offset.push(page.rows);
230
+ for await (const page of ctx.paginate.link({ url: 'https://api.x.com/l?page=1', rows: (res) => res.data.items })) out.link.push(page.rows);
231
+ yield ctx.record('item', out);`);
232
+ const http: HostHandler = (payload) => {
233
+ const request = payload as EgressHttpRequest;
234
+ if (request.url.startsWith('https://api.x.com/c')) {
235
+ return request.query.cursor === undefined ? httpResponse(200, { items: [1, 2], next: 'n2' }) : httpResponse(200, { items: [3], next: null });
236
+ }
237
+ if (request.url.startsWith('https://api.x.com/o')) {
238
+ return request.query.offset === '0' ? httpResponse(200, { items: ['a', 'b'] }) : httpResponse(200, { items: ['c'] });
239
+ }
240
+ return request.url.endsWith('page=1')
241
+ ? httpResponse(200, { items: ['x'] }, { link: '<https://api.x.com/l?page=2>; rel="next", <https://api.x.com/l?page=1>; rel="first"' }, request.url)
242
+ : httpResponse(200, { items: ['y'] }, { link: '<https://api.x.com/l?page=1>; rel="first"' }, request.url);
243
+ };
244
+ const run = await runGuest({ code, job: pullJob(), host: { http } });
245
+ expect(rows(run)).toEqual([{ cursor: [[1, 2], [3]], offset: [['a', 'b'], ['c']], link: [['x'], ['y']] }]);
246
+ expect(httpCalls(run).map((request) => request.url)).toEqual([
247
+ 'https://api.x.com/c', 'https://api.x.com/c', 'https://api.x.com/o', 'https://api.x.com/o', 'https://api.x.com/l?page=1', 'https://api.x.com/l?page=2',
248
+ ]);
249
+ });
250
+
251
+ test('keyset:按键翻页直到返回空', async () => {
252
+ const code = streamConnector(`
253
+ const pages = [];
254
+ for await (const page of ctx.paginate.keyset({
255
+ query: (last) => ctx.db.query('select id from t where id > $1 order by id limit 2', [last ?? 0]),
256
+ key: (row) => row.id,
257
+ })) pages.push(page.rows.map((r) => r.id));
258
+ yield ctx.record('item', { pages });`, { extra: { db: { engine: 'postgresql' } } });
259
+ const data = [1, 2, 3, 4, 5];
260
+ const run = await runGuest({
261
+ code, job: pullJob(),
262
+ host: {
263
+ 'db.query': (payload) => {
264
+ const after = (payload as { params: number[] }).params[0] ?? 0;
265
+ return { cursorId: null, fields: [{ name: 'id', dataTypeId: 23 }], rows: data.filter((id) => id > after).slice(0, 2).map((id) => ({ id })), done: true };
266
+ },
267
+ },
268
+ });
269
+ expect(rows(run)).toEqual([{ pages: [[1, 2], [3, 4], [5]] }]);
270
+ expect(run.calls.filter((entry) => entry.method === 'db.query').map((entry) => entry.payload)).toEqual([0, 2, 4, 5].map((last) => ({ sql: 'select id from t where id > $1 order by id limit 2', params: [last] })));
271
+ });
272
+ });
273
+
274
+ describe('ctx.db', () => {
275
+ const db = { 'db.query': () => ({ cursorId: 'c1', fields: [{ name: 'id', dataTypeId: 23 }], rows: Array.from({ length: 1000 }, (_, i) => ({ id: i + 11 })), done: false }) };
276
+
277
+ test('游标分批取完全部行', async () => {
278
+ const code = streamConnector(`
279
+ let count = 0; let last;
280
+ for await (const row of ctx.db.query('select * from t where id > $1 order by id', [10])) { count += 1; last = row.id; }
281
+ yield ctx.record('item', { count, last });`, { extra: { db: { engine: 'postgresql' } } });
282
+ const run = await runGuest({ code, job: pullJob(), host: { ...db, 'db.next': () => ({ rows: [{ id: 1011 }, { id: 1012 }], done: true }) } });
283
+ expect(rows(run)).toEqual([{ count: 1002, last: 1012 }]);
284
+ expect(run.calls.filter((entry) => entry.method.startsWith('db.'))).toEqual([
285
+ { method: 'db.query', payload: { sql: 'select * from t where id > $1 order by id', params: [10] } },
286
+ { method: 'db.next', payload: { cursorId: 'c1' } },
287
+ ]);
288
+ });
289
+
290
+ test('中途 break 时发 db.close', async () => {
291
+ const code = streamConnector(`
292
+ for await (const row of ctx.db.query('select * from t', [new Date('2026-01-01T00:00:00Z'), 10n, undefined])) { if (row.id > 12) break; }
293
+ yield ctx.record('item', {});`, { extra: { db: { engine: 'postgresql' } } });
294
+ const run = await runGuest({ code, job: pullJob(), host: db });
295
+ expectResult(run.outcome);
296
+ expect(run.calls.filter((entry) => entry.method.startsWith('db.'))).toEqual([
297
+ { method: 'db.query', payload: { sql: 'select * from t', params: ['2026-01-01T00:00:00.000Z', '10', null] } },
298
+ { method: 'db.close', payload: { cursorId: 'c1' } },
299
+ ]);
300
+ });
301
+ });
302
+
303
+ describe('ctx.map / cache / crypto / time', () => {
304
+ test('ctx.map 的并发不超过描述的 rateLimit.concurrency', async () => {
305
+ const code = streamConnector(`
306
+ const items = Array.from({ length: 20 }, (_, i) => i);
307
+ const out = await ctx.map(items, async (i) => (await ctx.http.get('https://api.x.com/i', { query: { i } })).data.i * 2, { concurrency: 8 });
308
+ yield ctx.record('item', { out });`, { extra: { rateLimit: { perSecond: 100, concurrency: 4 } } });
309
+ let inFlight = 0;
310
+ let peak = 0;
311
+ const run = await runGuest({
312
+ code, job: pullJob(),
313
+ host: {
314
+ http: async (payload) => {
315
+ inFlight += 1;
316
+ peak = Math.max(peak, inFlight);
317
+ await Bun.sleep(5);
318
+ inFlight -= 1;
319
+ return httpResponse(200, { i: Number((payload as EgressHttpRequest).query.i) });
320
+ },
321
+ },
322
+ });
323
+ expect(rows(run)).toEqual([{ out: Array.from({ length: 20 }, (_, i) => i * 2) }]);
324
+ expect(peak).toBe(4);
325
+ });
326
+
327
+ test('作业带生效限流(连接覆盖更小)时 ctx.map 按它收紧;更大时仍按描述', async () => {
328
+ const code = streamConnector(`
329
+ const items = Array.from({ length: 12 }, (_, i) => i);
330
+ const out = await ctx.map(items, async (i) => (await ctx.http.get('https://api.x.com/i', { query: { i } })).data.i, { concurrency: 8 });
331
+ yield ctx.record('item', { n: out.length });`, { extra: { rateLimit: { perSecond: 100, concurrency: 4 } } });
332
+ for (const [rateLimit, expected] of [[{ perSecond: 1, concurrency: 2 }, 2], [{ perSecond: 100, concurrency: 16 }, 4], [undefined, 4]] as const) {
333
+ let inFlight = 0;
334
+ let peak = 0;
335
+ const run = await runGuest({
336
+ code, job: pullJob(rateLimit ? { rateLimit } : {}),
337
+ host: {
338
+ http: async (payload) => {
339
+ inFlight += 1;
340
+ peak = Math.max(peak, inFlight);
341
+ await Bun.sleep(5);
342
+ inFlight -= 1;
343
+ return httpResponse(200, { i: Number((payload as EgressHttpRequest).query.i) });
344
+ },
345
+ },
346
+ });
347
+ expect(rows(run)).toEqual([{ n: 12 }]);
348
+ expect(peak).toBe(expected);
349
+ }
350
+ });
351
+
352
+ test('cache.getOrSet 未命中时调 fn 并 set,命中时读缓存', async () => {
353
+ const code = streamConnector(`
354
+ let calls = 0;
355
+ const fn = async () => { calls += 1; return 'token-' + calls; };
356
+ const first = await ctx.cache.getOrSet('session', 3000, fn);
357
+ const second = await ctx.cache.getOrSet('session', 3000, fn);
358
+ yield ctx.record('item', { first, second, calls });`);
359
+ const store = new Map<string, string>();
360
+ const run = await runGuest({
361
+ code, job: pullJob(),
362
+ host: {
363
+ 'cache.get': (payload) => {
364
+ const value = store.get((payload as { key: string }).key);
365
+ return value === undefined ? null : { value, expiresAt: new Date(Date.now() + 60_000).toISOString() };
366
+ },
367
+ 'cache.set': (payload) => {
368
+ const { key, value } = payload as { key: string; value: string };
369
+ store.set(key, value);
370
+ return null;
371
+ },
372
+ },
373
+ });
374
+ expect(rows(run)).toEqual([{ first: 'token-1', second: 'token-1', calls: 1 }]);
375
+ expect(run.calls.filter((entry) => entry.method.startsWith('cache.')).map((entry) => entry.method)).toEqual(['cache.get', 'cache.set', 'cache.get']);
376
+ expect(run.calls.find((entry) => entry.method === 'cache.set')?.payload).toEqual({ key: 'session', value: 'token-1', ttlSeconds: 3000 });
377
+ });
378
+
379
+ test('crypto:hmac / hash 走宿主,base64 在 guest 里按 UTF-8 编解码', async () => {
380
+ const code = streamConnector(`
381
+ const signature = await ctx.crypto.hmac('sha256', ctx.secrets.appSecret, 'data');
382
+ const digest = await ctx.crypto.hash('md5', 'abc', 'base64');
383
+ const encoded = ctx.crypto.base64Encode('金蝶 ok ✓');
384
+ yield ctx.record('item', { signature, digest, encoded, decoded: ctx.crypto.base64Decode(encoded) });`, { config: { appSecret: configField('secret') } });
385
+ const run = await runGuest({ code, job: pullJob({ secretFields: ['appSecret'] }), host: { crypto: (payload) => ({ value: `digest:${(payload as { op: string }).op}` }) } });
386
+ expect(rows(run)).toEqual([{ signature: 'digest:hmac', digest: 'digest:hash', encoded: Buffer.from('金蝶 ok ✓').toString('base64'), decoded: '金蝶 ok ✓' }]);
387
+ expect(run.calls.filter((entry) => entry.method === 'crypto').map((entry) => entry.payload)).toEqual([
388
+ { op: 'hmac', algorithm: 'sha256', key: 'ontos:secret:appSecret', data: 'data', encoding: 'hex' },
389
+ { op: 'hash', algorithm: 'md5', data: 'abc', encoding: 'base64' },
390
+ ]);
391
+ });
392
+
393
+ test('time.toUtc 按时区换算本地时间', async () => {
394
+ const code = streamConnector(`
395
+ yield ctx.record('item', {
396
+ shanghai: ctx.time.toUtc('2026-10-25 08:00:00', 'Asia/Shanghai'),
397
+ newYorkDst: ctx.time.toUtc('2026-07-01T12:30:00', 'America/New_York'),
398
+ formatted: ctx.time.toUtc('25/10/2026 08:00', 'Asia/Shanghai', 'DD/MM/YYYY HH:mm'),
399
+ zoned: ctx.time.toUtc('2026-10-25T08:00:00+08:00', 'UTC'),
400
+ now: typeof ctx.time.now(),
401
+ });`);
402
+ const run = await runGuest({ code, job: pullJob() });
403
+ expect(rows(run)).toEqual([{
404
+ shanghai: '2026-10-25T00:00:00.000Z', newYorkDst: '2026-07-01T16:30:00.000Z', formatted: '2026-10-25T00:00:00.000Z',
405
+ zoned: '2026-10-25T00:00:00.000Z', now: 'string',
406
+ }]);
407
+ });
408
+ });
409
+
410
+ describe('ctx.signal / ctx.log', () => {
411
+ test('宿主发 cancel 后 signal.aborted 为 true,onAbort 回调被调用', async () => {
412
+ const code = checkConnector(`
413
+ let called = false;
414
+ ctx.signal.onAbort(() => { called = true; });
415
+ const before = ctx.signal.aborted;
416
+ try { await ctx.http.get('https://api.x.com/slow'); } catch (e) { ctx.log.info(JSON.stringify({ before, after: ctx.signal.aborted, called, code: e.code })); }`);
417
+ const run = await runGuest({ code, job: checkJob(), host: { http: (_payload, host) => { host.cancel(); return new Promise(() => undefined); } } });
418
+ const failure = expectFailure(run.outcome);
419
+ expect(failure.code).toBe('CANCELED');
420
+ expect(JSON.parse(failure.logs[0]?.message ?? '{}')).toEqual({ before: false, after: true, called: true, code: 'CANCELED' });
421
+ });
422
+
423
+ test('ctx.log 一次运行最多保留 1000 条,单条超过 4096 字符截断', async () => {
424
+ const code = checkConnector(`
425
+ ctx.log.warn('x'.repeat(5000));
426
+ for (let i = 0; i < 1000; i += 1) ctx.log.info('line ' + i, { i });`);
427
+ const result = expectResult((await runGuest({ code, job: checkJob() })).outcome);
428
+ expect(result.logs).toHaveLength(1000);
429
+ expect(result.logs[0]).toMatchObject({ level: 'warn' });
430
+ expect(result.logs[0]?.message).toHaveLength(4096);
431
+ expect(result.logs[1]?.message).toBe('line 0 {"i":0}');
432
+ expect(result.logs[999]?.message).toBe('line 998 {"i":998}');
433
+ });
434
+ });
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 运行时测试的假宿主:在测试进程里直接建 guest 会话(vm 上下文),按方法回应 `call`。
3
+ * 每条子进程消息都先过一遍 `parseChildMessage`,顺带验证消息符合协议。
4
+ */
5
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import type { BunPlugin, Loader } from 'bun';
9
+ import {
10
+ createGuestSession, GUEST_ENTRY_PATH, parseChildMessage, type ChildMessage, type GuestJob, type HostCallError, type HostMethod,
11
+ } from '../../src/runtime';
12
+
13
+ const LOADERS: Readonly<Record<string, Loader>> = { ts: 'ts', mts: 'ts', cts: 'ts', tsx: 'tsx', js: 'js', mjs: 'js', cjs: 'js', jsx: 'jsx' };
14
+
15
+ /** Bun 1.3:当前进程已 import 过的源文件再交给 `Bun.build` 会报「Unexpected reading file」,经插件自己读文件绕开。 */
16
+ const readSourcePlugin: BunPlugin = {
17
+ name: 'sdk-test-read-source',
18
+ setup(build) {
19
+ build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
20
+ const extension = args.path.slice(args.path.lastIndexOf('.') + 1);
21
+ return { contents: await Bun.file(args.path).text(), loader: LOADERS[extension] ?? 'ts' };
22
+ });
23
+ },
24
+ };
25
+
26
+ async function build(entry: string, external: string[] = []): Promise<string> {
27
+ const result = await Bun.build({ entrypoints: [entry], format: 'cjs', target: 'browser', minify: false, external, plugins: [readSourcePlugin] });
28
+ const output = result.outputs[0];
29
+ if (!result.success || !output) throw new Error(`bundle failed: ${result.logs.map((log) => log.message).join('; ')}`);
30
+ return output.text();
31
+ }
32
+
33
+ let guestBundle: Promise<string> | undefined;
34
+
35
+ /** 与 Phase 2 的 `bundleTrustedEntry` 参数一致:cjs、browser、不压缩。 */
36
+ export function bundleGuest(): Promise<string> {
37
+ guestBundle ??= build(GUEST_ENTRY_PATH);
38
+ return guestBundle;
39
+ }
40
+
41
+ /** 把 TS 连接器源码打成 CJS,SDK 为 external(与平台打包一致)。 */
42
+ export async function bundleConnector(source: string): Promise<string> {
43
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'sdk-runtime-'));
44
+ try {
45
+ const entry = path.join(dir, 'index.ts');
46
+ await writeFile(entry, source);
47
+ return await build(entry, ['@deepexi/datasense-connector']);
48
+ } finally {
49
+ await rm(dir, { recursive: true, force: true });
50
+ }
51
+ }
52
+
53
+ export class HostReject extends Error {
54
+ readonly error: HostCallError;
55
+
56
+ constructor(error: HostCallError) {
57
+ super(error.message);
58
+ this.error = error;
59
+ }
60
+ }
61
+
62
+ export type HostHandler = (payload: unknown, session: FakeHost) => unknown;
63
+
64
+ export interface FakeHost {
65
+ calls: Array<{ method: HostMethod; payload: unknown }>;
66
+ messages: ChildMessage[];
67
+ cancel(): void;
68
+ }
69
+
70
+ export interface RunOptions {
71
+ code: string;
72
+ job: GuestJob;
73
+ expect?: { name: string; version: string };
74
+ host?: Partial<Record<HostMethod, HostHandler>>;
75
+ /** 每条消息之后调用(例如在某次调用之后发 cancel)。 */
76
+ onMessage?(message: ChildMessage, host: FakeHost): void;
77
+ timeoutMs?: number;
78
+ }
79
+
80
+ export type Outcome = Extract<ChildMessage, { type: 'result' | 'failure' }>;
81
+
82
+ export interface RunResult extends FakeHost {
83
+ outcome: Outcome;
84
+ }
85
+
86
+ export const DEFAULT_EXPECT = { name: 'demo', version: '1.0.0' };
87
+
88
+ export async function runGuest(options: RunOptions): Promise<RunResult> {
89
+ const guest = await bundleGuest();
90
+ const job = options.job;
91
+ return new Promise<RunResult>((resolve, reject) => {
92
+ const host: FakeHost = {
93
+ calls: [],
94
+ messages: [],
95
+ cancel: () => session.handle(JSON.stringify({ type: 'cancel' })),
96
+ };
97
+ const timer = setTimeout(() => reject(new Error(`guest did not finish; messages: ${JSON.stringify(host.messages)}`)), options.timeoutMs ?? 10_000);
98
+ const reply = (id: number, handler: HostHandler | undefined, payload: unknown) => {
99
+ Promise.resolve()
100
+ .then(() => (handler ? handler(payload, host) : null))
101
+ .then(
102
+ (value) => session.handle(JSON.stringify({ type: 'reply', id, ok: true, value: value ?? null })),
103
+ (error: unknown) => {
104
+ const failure = error instanceof HostReject ? error.error : { code: 'HOST_TEST_ERROR', message: String(error) };
105
+ session.handle(JSON.stringify({ type: 'reply', id, ok: false, error: failure }));
106
+ },
107
+ );
108
+ };
109
+ const session = createGuestSession((json) => {
110
+ const message = parseChildMessage(JSON.parse(json), job);
111
+ if (!message) {
112
+ clearTimeout(timer);
113
+ reject(new Error(`malformed child message: ${json}`));
114
+ return;
115
+ }
116
+ host.messages.push(message);
117
+ if (message.type === 'call') {
118
+ host.calls.push({ method: message.method, payload: message.payload });
119
+ reply(message.id, options.host?.[message.method], message.payload);
120
+ }
121
+ options.onMessage?.(message, host);
122
+ if (message.type === 'result' || message.type === 'failure') {
123
+ clearTimeout(timer);
124
+ resolve({ ...host, outcome: message });
125
+ }
126
+ });
127
+ session.handle(JSON.stringify({ type: 'execute', guest, code: options.code, expect: options.expect ?? DEFAULT_EXPECT, job }));
128
+ });
129
+ }
130
+
131
+ export const checkJob = (config: Record<string, unknown> = {}, secretFields: string[] = []): GuestJob => ({ kind: 'check', config, secretFields });
132
+
133
+ export const suggestJob = (stream: string, table: string, config: Record<string, unknown> = {}): GuestJob => ({ kind: 'suggest', config, secretFields: [], stream, table });
134
+
135
+ export const pullJob = (overrides: Partial<Extract<GuestJob, { kind: 'pull' }>> = {}): GuestJob => ({
136
+ kind: 'pull', config: {}, secretFields: [], stream: 'items', mode: 'full', state: null, streamConfig: {}, tables: ['item'], ...overrides,
137
+ });
138
+
139
+ export const pushJob = (requests: Array<{ requestId: string; push?: string; body: string }>, config: Record<string, unknown> = {}): GuestJob => ({
140
+ kind: 'push', config, secretFields: [],
141
+ requests: requests.map((request) => ({
142
+ requestId: request.requestId, push: request.push ?? 'default', receivedAt: '2026-10-25T00:00:00.000Z',
143
+ headers: { 'content-type': 'application/json' }, body: request.body,
144
+ })),
145
+ });
146
+
147
+ export { rawConnector } from '../fixtures/descriptor';
148
+
149
+ export function expectFailure(outcome: Outcome): Extract<ChildMessage, { type: 'failure' }> {
150
+ if (outcome.type !== 'failure') throw new Error(`expected failure, got ${JSON.stringify(outcome)}`);
151
+ return outcome;
152
+ }
153
+
154
+ export function expectResult(outcome: Outcome): Extract<ChildMessage, { type: 'result' }>['result'] {
155
+ if (outcome.type !== 'result') throw new Error(`expected result, got ${JSON.stringify(outcome)}`);
156
+ return outcome.result;
157
+ }