@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,238 @@
1
+ /**
2
+ * guest 与宿主之间的唯一通道(在 vm 上下文**里**执行)。
3
+ *
4
+ * 宿主只调用 guest 交出的三个函数,参数与返回值只有字符串和上下文自己的值:
5
+ * - `handle(json)`:父进程的 `reply` / `cancel`;
6
+ * - `poll()`:取走待发的消息(`call` / `result` / `failure`),同时触发到期的定时器,返回 JSON 字符串;
7
+ * - `start(json, factory)`:见 `index.ts`。
8
+ * 宿主不往上下文里放任何函数或对象(宿主值的 `constructor` 链能拿到宿主的 `Function`),也不 `await`
9
+ * 上下文里的 Promise。上下文里没有 `setTimeout`,`sleep` 由宿主按 `poll()` 回报的 `wake` 定时再调 `poll()` 实现。
10
+ *
11
+ * 用到的内置函数在本模块求值时取引用:guest 打包产物先于连接器代码执行,连接器之后改写全局不影响这里。
12
+ */
13
+ import { ConnectorError } from '../../errors';
14
+ import type { HostMethod } from '../protocol';
15
+
16
+ const jsonParse = JSON.parse;
17
+ const jsonStringify = JSON.stringify;
18
+ const dateNow = Date.now;
19
+ const reflectApply = Reflect.apply;
20
+ const mathMin = Math.min;
21
+ const MapCtor = Map;
22
+
23
+ export { jsonParse, jsonStringify };
24
+
25
+ /** 宿主调用失败:`code` 来自宿主(如 `EGRESS_RATE_LIMITED`),连接器按 `code` 判断。 */
26
+ export class HostCallFailure extends ConnectorError {
27
+ readonly status: number | undefined;
28
+ readonly retryAfterMs: number | undefined;
29
+
30
+ constructor(code: string, message: string, status?: number, retryAfterMs?: number) {
31
+ super(code, message);
32
+ this.name = 'HostCallFailure';
33
+ this.status = status;
34
+ this.retryAfterMs = retryAfterMs;
35
+ }
36
+ }
37
+
38
+ /** 运行被取消。 */
39
+ export class CanceledError extends ConnectorError {
40
+ constructor() {
41
+ super('CANCELED', 'run was canceled');
42
+ this.name = 'CanceledError';
43
+ }
44
+ }
45
+
46
+ interface PendingCall {
47
+ resolve(value: unknown): void;
48
+ reject(error: unknown): void;
49
+ }
50
+
51
+ interface Timer {
52
+ at: number;
53
+ resolve(): void;
54
+ }
55
+
56
+ let nextCallId = 1;
57
+ const pending = new MapCtor<number, PendingCall>();
58
+ let timers: Timer[] = [];
59
+ let outbox: string[] = [];
60
+ let finished = false;
61
+ let aborted = false;
62
+ /** 宿主经 `emit` 回复要求提前结束(预览达到条数上限):与取消一样触发 `ctx.signal`,但作业正常结束。 */
63
+ let stopped = false;
64
+ const abortListeners: Array<() => void> = [];
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ /** 当前时间(毫秒)。 */
71
+ export function now(): number {
72
+ return reflectApply(dateNow, Date, []) as number;
73
+ }
74
+
75
+ /** 把消息排进待发队列;不可序列化时同步抛错,由调用方按连接器异常处理。 */
76
+ function enqueue(message: Record<string, unknown>): void {
77
+ const text = jsonStringify(message);
78
+ if (typeof text !== 'string') throw new TypeError('message is not serializable');
79
+ outbox[outbox.length] = text;
80
+ }
81
+
82
+ /** 置位后任何宿主调用都直接失败(连接器操作的 `simulate` 是纯函数,期间不允许调用宿主)。 */
83
+ let hostCallsDenied = false;
84
+
85
+ /** 从此以后拒绝宿主调用(本次运行内不可恢复)。 */
86
+ export function denyHostCalls(): void {
87
+ hostCallsDenied = true;
88
+ }
89
+
90
+ /** 发一次宿主调用,等父进程的 `reply`。 */
91
+ export function call(method: HostMethod, payload: unknown): Promise<unknown> {
92
+ if (hostCallsDenied) return Promise.reject(new ConnectorError('HOST_CALL_DENIED', `${method} is not available in simulate()`));
93
+ const id = nextCallId;
94
+ nextCallId += 1;
95
+ return new Promise<unknown>((resolve, reject) => {
96
+ try {
97
+ enqueue({ type: 'call', id, method, payload });
98
+ } catch (error) {
99
+ reject(new TypeError(`${method} payload is not serializable: ${error instanceof Error ? error.message : String(error)}`));
100
+ return;
101
+ }
102
+ pending.set(id, { resolve, reject });
103
+ });
104
+ }
105
+
106
+ /** 等待 `ms` 毫秒;运行被取消时提前结束(调用方随后检查 `isAborted()`)。 */
107
+ export function sleep(ms: number): Promise<void> {
108
+ if (aborted || !(ms > 0)) return Promise.resolve();
109
+ return new Promise<void>((resolve) => {
110
+ timers[timers.length] = { at: now() + ms, resolve };
111
+ });
112
+ }
113
+
114
+ export function isAborted(): boolean {
115
+ return aborted;
116
+ }
117
+
118
+ /** 是否因宿主要求提前结束(而不是取消)而中止。 */
119
+ export function isStopped(): boolean {
120
+ return stopped;
121
+ }
122
+
123
+ /** 宿主要求提前结束:触发 `ctx.signal`(`aborted` 置位、回调执行),拉取随后以 `stopped: true` 正常结束。 */
124
+ export function requestStop(): void {
125
+ if (aborted) return;
126
+ stopped = true;
127
+ abort();
128
+ }
129
+
130
+ export function throwIfAborted(): void {
131
+ if (aborted) throw new CanceledError();
132
+ }
133
+
134
+ export function onAbort(listener: () => void): void {
135
+ if (aborted) {
136
+ void Promise.resolve().then(() => runListener(listener));
137
+ return;
138
+ }
139
+ abortListeners[abortListeners.length] = listener;
140
+ }
141
+
142
+ function runListener(listener: () => void): void {
143
+ try {
144
+ listener();
145
+ } catch {
146
+ // 连接器的取消回调出错不影响取消本身
147
+ }
148
+ }
149
+
150
+ function abort(): void {
151
+ if (aborted) return;
152
+ aborted = true;
153
+ // 正在等待的定时器立即结束,让重试与退避尽快看到取消。
154
+ const due = timers;
155
+ timers = [];
156
+ for (const timer of due) timer.resolve();
157
+ // 在途的宿主调用不再等回复:父进程随后会结束子进程。
158
+ for (const [id, entry] of pending) {
159
+ pending.delete(id);
160
+ entry.reject(new CanceledError());
161
+ }
162
+ const listeners = abortListeners.splice(0, abortListeners.length);
163
+ for (const listener of listeners) runListener(listener);
164
+ }
165
+
166
+ /** 发出最终结果(`result` / `failure`);只生效一次。 */
167
+ export function finish(message: Record<string, unknown>): void {
168
+ if (finished) return;
169
+ finished = true;
170
+ try {
171
+ enqueue(message);
172
+ } catch (error) {
173
+ outbox[outbox.length] = jsonStringify({
174
+ type: 'failure', code: 'FATAL_ERROR', logs: [],
175
+ message: `connector result is not serializable: ${error instanceof Error ? error.message : String(error)}`,
176
+ });
177
+ }
178
+ }
179
+
180
+ export function isFinished(): boolean {
181
+ return finished;
182
+ }
183
+
184
+ /** 父进程消息(已由宿主侧 `parseParentMessage` 校验并重新序列化)。 */
185
+ export function handle(json: string): void {
186
+ if (typeof json !== 'string') return;
187
+ let message: unknown;
188
+ try {
189
+ message = jsonParse(json);
190
+ } catch {
191
+ return;
192
+ }
193
+ if (!isRecord(message)) return;
194
+ if (message.type === 'cancel') {
195
+ abort();
196
+ return;
197
+ }
198
+ if (message.type !== 'reply' || typeof message.id !== 'number') return;
199
+ const entry = pending.get(message.id);
200
+ if (!entry) return;
201
+ pending.delete(message.id);
202
+ if (message.ok === true) {
203
+ entry.resolve(message.value);
204
+ return;
205
+ }
206
+ const error = isRecord(message.error) ? message.error : {};
207
+ entry.reject(new HostCallFailure(
208
+ typeof error.code === 'string' ? error.code : 'HOST_ERROR',
209
+ typeof error.message === 'string' ? error.message : 'host call failed',
210
+ typeof error.status === 'number' ? error.status : undefined,
211
+ typeof error.retryAfterMs === 'number' ? error.retryAfterMs : undefined,
212
+ ));
213
+ }
214
+
215
+ /**
216
+ * 触发到期的定时器并取走待发消息。返回 `{ out, wake, fired }`:`out` 是待发消息的 JSON 字符串,
217
+ * `wake` 是距下一个定时器的毫秒数(没有则为 -1),`fired` 表示本次触发了定时器(宿主应在微任务跑完后再 poll 一次)。
218
+ */
219
+ export function poll(): string {
220
+ const current = now();
221
+ let fired = false;
222
+ if (timers.length > 0) {
223
+ const due: Timer[] = [];
224
+ const rest: Timer[] = [];
225
+ for (const timer of timers) {
226
+ if (timer.at <= current) due[due.length] = timer;
227
+ else rest[rest.length] = timer;
228
+ }
229
+ timers = rest;
230
+ for (const timer of due) timer.resolve();
231
+ fired = due.length > 0;
232
+ }
233
+ let wake = -1;
234
+ for (const timer of timers) wake = wake < 0 ? timer.at - current : mathMin(wake, timer.at - current);
235
+ const out = outbox;
236
+ outbox = [];
237
+ return `{"out":[${out.join(',')}],"wake":${wake < 0 ? -1 : wake},"fired":${fired ? 'true' : 'false'}}`;
238
+ }
@@ -0,0 +1,43 @@
1
+ /** `ctx.cache`:按连接隔离、跨运行保留的小型键值缓存,存在宿主侧(加密存储)。 */
2
+ import type { ConnectorCache } from '../../types';
3
+ import type { CacheEntry } from '../protocol';
4
+ import { call, now } from './bridge';
5
+
6
+ function key(value: unknown): string {
7
+ if (typeof value !== 'string' || value.length === 0) throw new TypeError('cache key must be a non-empty string');
8
+ return value;
9
+ }
10
+
11
+ function ttl(value: unknown): number {
12
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) throw new TypeError('cache ttlSeconds must be a positive integer');
13
+ return value;
14
+ }
15
+
16
+ async function get(name: string): Promise<string | undefined> {
17
+ const entry = await call('cache.get', { key: key(name) }) as CacheEntry | null;
18
+ if (entry === null || typeof entry !== 'object' || typeof entry.value !== 'string') return undefined;
19
+ const expiresAt = typeof entry.expiresAt === 'string' ? Date.parse(entry.expiresAt) : Number.NaN;
20
+ if (!Number.isNaN(expiresAt) && expiresAt <= now()) return undefined;
21
+ return entry.value;
22
+ }
23
+
24
+ async function set(name: string, value: string, ttlSeconds: number): Promise<void> {
25
+ if (typeof value !== 'string') throw new TypeError('cache value must be a string');
26
+ await call('cache.set', { key: key(name), value, ttlSeconds: ttl(ttlSeconds) });
27
+ }
28
+
29
+ export function createCache(): ConnectorCache {
30
+ return Object.freeze({
31
+ get: (name: string) => Promise.resolve().then(() => get(name)),
32
+ set: (name: string, value: string, ttlSeconds: number) => Promise.resolve().then(() => set(name, value, ttlSeconds)),
33
+ async getOrSet(name: string, ttlSeconds: number, fn: () => Promise<string>): Promise<string> {
34
+ ttl(ttlSeconds);
35
+ if (typeof fn !== 'function') throw new TypeError('cache.getOrSet expects a function');
36
+ const cached = await get(name);
37
+ if (cached !== undefined) return cached;
38
+ const value = await fn();
39
+ await set(name, value, ttlSeconds);
40
+ return value;
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,17 @@
1
+ /** check 作业:只跑连接器的 `check`(描述里没有 `check` 时直接成功),结果 `{ kind: 'check', ok: true, logs }`。 */
2
+ import type { GuestResult } from '../protocol';
3
+ import { throwIfAborted } from './bridge';
4
+ import { createCheckContext, type ContextOptions } from './ctx';
5
+ import { failureFromError, type FailureMessage } from './failure';
6
+ import type { LoadedConnector } from './loader';
7
+
8
+ export async function runCheck(connector: LoadedConnector, options: ContextOptions): Promise<{ type: 'result'; result: GuestResult } | FailureMessage> {
9
+ const ctx = createCheckContext(options);
10
+ try {
11
+ if (connector.check) await Reflect.apply(connector.check, undefined, [ctx]);
12
+ throwIfAborted();
13
+ } catch (error) {
14
+ return failureFromError(error, options.logs.drain());
15
+ }
16
+ return { type: 'result', result: { kind: 'check', ok: true, logs: options.logs.drain() } };
17
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `ctx.crypto`:`hmac` / `sign` / `hash` 走宿主(vm 上下文里没有 WebCrypto;`key` 可以是凭证句柄,由网关代算),
3
+ * `base64Encode` / `base64Decode` 在 guest 里纯 JS 实现(按 UTF-8)。
4
+ */
5
+ import type { ConnectorCrypto, DigestEncoding, HashAlgorithm, HmacAlgorithm, SignAlgorithm } from '../../types';
6
+ import type { CryptoRequest, CryptoResponse } from '../protocol';
7
+ import { call } from './bridge';
8
+
9
+ const HMAC_ALGORITHMS: readonly string[] = ['sha1', 'sha256', 'sha512'];
10
+ const HASH_ALGORITHMS: readonly string[] = ['md5', 'sha1', 'sha256', 'sha512'];
11
+ const ENCODINGS: readonly string[] = ['hex', 'base64'];
12
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
13
+
14
+ function encoding(value: unknown): DigestEncoding {
15
+ if (value === undefined) return 'hex';
16
+ if (typeof value !== 'string' || !ENCODINGS.includes(value)) throw new TypeError("encoding must be 'hex' or 'base64'");
17
+ return value as DigestEncoding;
18
+ }
19
+
20
+ function text(value: unknown, what: string): string {
21
+ if (typeof value !== 'string') throw new TypeError(`${what} must be a string`);
22
+ return value;
23
+ }
24
+
25
+ async function digest(request: CryptoRequest): Promise<string> {
26
+ const response = await call('crypto', request) as CryptoResponse | null;
27
+ if (!response || typeof response.value !== 'string') throw new TypeError('crypto host call returned no value');
28
+ return response.value;
29
+ }
30
+
31
+ export function utf8Encode(input: string): number[] {
32
+ const bytes: number[] = [];
33
+ for (let i = 0; i < input.length; i += 1) {
34
+ let code = input.charCodeAt(i);
35
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < input.length) {
36
+ const low = input.charCodeAt(i + 1);
37
+ if (low >= 0xdc00 && low <= 0xdfff) {
38
+ code = 0x10000 + ((code - 0xd800) << 10) + (low - 0xdc00);
39
+ i += 1;
40
+ }
41
+ }
42
+ if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd;
43
+ if (code < 0x80) bytes.push(code);
44
+ else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 63));
45
+ else if (code < 0x10000) bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
46
+ else bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
47
+ }
48
+ return bytes;
49
+ }
50
+
51
+ /** UTF-8 字节数,用于批次大小统计。 */
52
+ export function utf8Length(input: string): number {
53
+ let length = 0;
54
+ for (let i = 0; i < input.length; i += 1) {
55
+ const code = input.charCodeAt(i);
56
+ if (code < 0x80) length += 1;
57
+ else if (code < 0x800) length += 2;
58
+ else if (code >= 0xd800 && code <= 0xdbff && i + 1 < input.length) {
59
+ const low = input.charCodeAt(i + 1);
60
+ if (low >= 0xdc00 && low <= 0xdfff) {
61
+ length += 4;
62
+ i += 1;
63
+ } else {
64
+ length += 3;
65
+ }
66
+ } else length += 3;
67
+ }
68
+ return length;
69
+ }
70
+
71
+ function utf8Decode(bytes: readonly number[]): string {
72
+ let out = '';
73
+ let i = 0;
74
+ while (i < bytes.length) {
75
+ const b0 = bytes[i] ?? 0;
76
+ let code = 0xfffd;
77
+ let size = 1;
78
+ const cont = (offset: number) => {
79
+ const b = bytes[i + offset];
80
+ return b !== undefined && (b & 0xc0) === 0x80 ? b & 63 : -1;
81
+ };
82
+ if (b0 < 0x80) {
83
+ code = b0;
84
+ } else if (b0 >= 0xc2 && b0 < 0xe0) {
85
+ const b1 = cont(1);
86
+ if (b1 >= 0) { code = ((b0 & 31) << 6) | b1; size = 2; }
87
+ } else if (b0 >= 0xe0 && b0 < 0xf0) {
88
+ const b1 = cont(1);
89
+ const b2 = cont(2);
90
+ if (b1 >= 0 && b2 >= 0) {
91
+ const value = ((b0 & 15) << 12) | (b1 << 6) | b2;
92
+ if (value >= 0x800 && (value < 0xd800 || value > 0xdfff)) { code = value; size = 3; }
93
+ }
94
+ } else if (b0 >= 0xf0 && b0 < 0xf5) {
95
+ const b1 = cont(1);
96
+ const b2 = cont(2);
97
+ const b3 = cont(3);
98
+ if (b1 >= 0 && b2 >= 0 && b3 >= 0) {
99
+ const value = ((b0 & 7) << 18) | (b1 << 12) | (b2 << 6) | b3;
100
+ if (value >= 0x10000 && value <= 0x10ffff) { code = value; size = 4; }
101
+ }
102
+ }
103
+ out += String.fromCodePoint(code);
104
+ i += size;
105
+ }
106
+ return out;
107
+ }
108
+
109
+ export function bytesToBase64(bytes: readonly number[]): string {
110
+ let out = '';
111
+ for (let i = 0; i < bytes.length; i += 3) {
112
+ const b0 = bytes[i] ?? 0;
113
+ const b1 = bytes[i + 1];
114
+ const b2 = bytes[i + 2];
115
+ const chunk = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0);
116
+ out += ALPHABET.charAt((chunk >> 18) & 63) + ALPHABET.charAt((chunk >> 12) & 63);
117
+ out += b1 === undefined ? '=' : ALPHABET.charAt((chunk >> 6) & 63);
118
+ out += b2 === undefined ? '=' : ALPHABET.charAt(chunk & 63);
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function base64ToBytes(input: string): number[] {
124
+ const clean = input.replace(/[\s=]/g, '').replace(/-/g, '+').replace(/_/g, '/');
125
+ const bytes: number[] = [];
126
+ let buffer = 0;
127
+ let bits = 0;
128
+ for (let i = 0; i < clean.length; i += 1) {
129
+ const value = ALPHABET.indexOf(clean.charAt(i));
130
+ if (value < 0) throw new TypeError('invalid base64 text');
131
+ buffer = (buffer << 6) | value;
132
+ bits += 6;
133
+ if (bits >= 8) {
134
+ bits -= 8;
135
+ bytes.push((buffer >> bits) & 0xff);
136
+ }
137
+ }
138
+ return bytes;
139
+ }
140
+
141
+ export function base64Encode(input: string): string {
142
+ return bytesToBase64(utf8Encode(text(input, 'base64Encode input')));
143
+ }
144
+
145
+ export function base64Decode(input: string): string {
146
+ return utf8Decode(base64ToBytes(text(input, 'base64Decode input')));
147
+ }
148
+
149
+ export function createCrypto(): ConnectorCrypto {
150
+ return Object.freeze({
151
+ hmac(algorithm: HmacAlgorithm, key: string, data: string, enc?: DigestEncoding): Promise<string> {
152
+ if (!HMAC_ALGORITHMS.includes(algorithm)) return Promise.reject(new TypeError(`unsupported hmac algorithm ${String(algorithm)}`));
153
+ return Promise.resolve().then(() => digest({ op: 'hmac', algorithm, key: text(key, 'hmac key'), data: text(data, 'hmac data'), encoding: encoding(enc) }));
154
+ },
155
+ sign(algorithm: SignAlgorithm, key: string, data: string, enc?: DigestEncoding): Promise<string> {
156
+ if (algorithm !== 'rsa-sha256') return Promise.reject(new TypeError(`unsupported sign algorithm ${String(algorithm)}`));
157
+ return Promise.resolve().then(() => digest({ op: 'sign', algorithm, key: text(key, 'sign key'), data: text(data, 'sign data'), encoding: encoding(enc) }));
158
+ },
159
+ hash(algorithm: HashAlgorithm, data: string, enc?: DigestEncoding): Promise<string> {
160
+ if (!HASH_ALGORITHMS.includes(algorithm)) return Promise.reject(new TypeError(`unsupported hash algorithm ${String(algorithm)}`));
161
+ return Promise.resolve().then(() => digest({ op: 'hash', algorithm, data: text(data, 'hash data'), encoding: encoding(enc) }));
162
+ },
163
+ base64Encode,
164
+ base64Decode,
165
+ });
166
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * 拉取、check 与建议列用的 `ctx`。所有成员都在上下文里定义(guest 自己的函数与对象),
3
+ * 宿主能力只经 `bridge.call` 的 JSON 消息取得。
4
+ */
5
+ import { errors } from '../../errors';
6
+ import type { ConnectorDescriptor } from '../../descriptor';
7
+ import type { CheckContext, CheckpointItem, PullContext, RecordItem, RemoveItem } from '../../types';
8
+ import type { JobRateLimit, SecretRevealResponse } from '../protocol';
9
+ import { call } from './bridge';
10
+ import { createCache } from './cache';
11
+ import { createCrypto } from './crypto';
12
+ import { createDb } from './db';
13
+ import { createHttp } from './http';
14
+ import { DEFAULT_CONCURRENCY } from './limits';
15
+ import type { LogBuffer } from './log';
16
+ import { createMap } from './map';
17
+ import { createPaginate } from './paginate';
18
+ import { createSignal } from './signal';
19
+ import { createTime } from './time';
20
+
21
+ /** 凭证句柄的前缀,与 shared 的 `SECRET_HANDLE_PREFIX` 同值。 */
22
+ export const SECRET_HANDLE_PREFIX = 'ontos:secret:';
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ export function deepFreeze<T>(value: T): T {
29
+ if (value === null || typeof value !== 'object') return value;
30
+ for (const key of Object.keys(value)) deepFreeze((value as Record<string, unknown>)[key]);
31
+ return Object.freeze(value);
32
+ }
33
+
34
+ function isHttpUrl(text: string): boolean {
35
+ try {
36
+ const url = new URL(text);
37
+ return url.protocol === 'http:' || url.protocol === 'https:';
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * 相对路径的缺省基址:按声明顺序第一个有值的 `url` 配置项(原样,可带路径);没有时取 `hosts` 里第一个确定的主机(不含 `*`);都没有为 null。
45
+ */
46
+ export function defaultBaseUrl(descriptor: Pick<ConnectorDescriptor, 'config' | 'hosts'>, config: Record<string, unknown>): string | null {
47
+ for (const [name, field] of Object.entries(descriptor.config)) {
48
+ if (field.type !== 'url') continue;
49
+ const value = config[name];
50
+ if (typeof value === 'string' && isHttpUrl(value)) return value;
51
+ }
52
+ for (const host of descriptor.hosts) {
53
+ if (!host.includes('*')) return `https://${host}`;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** 描述里的凭证配置项:名字与是否 `exposeToScript`。 */
59
+ function secretFieldsOf(descriptor: Pick<ConnectorDescriptor, 'config'>): Map<string, boolean> {
60
+ const fields = new Map<string, boolean>();
61
+ for (const [name, field] of Object.entries(descriptor.config)) {
62
+ if (field.type === 'secret') fields.set(name, field.exposeToScript === true);
63
+ }
64
+ return fields;
65
+ }
66
+
67
+ /**
68
+ * `ctx.secrets`:每个已配置的凭证字段给句柄 `ontos:secret:<字段>`;`exposeToScript` 的字段经 `secret.reveal`
69
+ * 取明文。`ctx.secrets` 是同步读取的普通对象,所以这些字段在作业开始前各取一次,缓存到本次运行结束。
70
+ */
71
+ export async function prepareSecrets(descriptor: Pick<ConnectorDescriptor, 'config'>, secretFields: readonly string[]): Promise<Readonly<Record<string, string>>> {
72
+ const declared = secretFieldsOf(descriptor);
73
+ const secrets: Record<string, string> = {};
74
+ for (const field of secretFields) {
75
+ const exposed = declared.get(field);
76
+ if (exposed === undefined) continue;
77
+ if (exposed) {
78
+ const response = await call('secret.reveal', { field }) as SecretRevealResponse | null;
79
+ if (!isRecord(response) || typeof response.value !== 'string') throw new TypeError(`secret.reveal returned no value for ${field}`);
80
+ secrets[field] = response.value;
81
+ } else {
82
+ secrets[field] = `${SECRET_HANDLE_PREFIX}${field}`;
83
+ }
84
+ }
85
+ return Object.freeze(secrets);
86
+ }
87
+
88
+ export interface ContextOptions {
89
+ config: Record<string, unknown>;
90
+ secrets: Readonly<Record<string, string>>;
91
+ descriptor: ConnectorDescriptor;
92
+ logs: LogBuffer;
93
+ /** 作业里宿主给出的生效限流(连接覆盖与描述取小);没给时只按描述。 */
94
+ rateLimit?: JobRateLimit | undefined;
95
+ }
96
+
97
+ /**
98
+ * `ctx.map` 的并发上限:描述的 `rateLimit.concurrency`(缺省 4)与作业的生效并发取小。
99
+ * 网关按「描述与连接覆盖取小」限流,`ctx.map` 的上限要跟它一致,否则覆盖更小时会一直撞 429。
100
+ */
101
+ function mapConcurrency(options: ContextOptions): number {
102
+ const declared = options.descriptor.rateLimit?.concurrency ?? DEFAULT_CONCURRENCY;
103
+ const effective = options.rateLimit?.concurrency;
104
+ return typeof effective === 'number' && Number.isFinite(effective) && effective >= 1 ? Math.min(declared, Math.floor(effective)) : declared;
105
+ }
106
+
107
+ function tableName(table: unknown): string {
108
+ if (typeof table !== 'string' || table.length === 0) throw new TypeError('table must be a non-empty string');
109
+ return table;
110
+ }
111
+
112
+ function plainObject(value: unknown, what: string): Record<string, unknown> {
113
+ if (!isRecord(value)) throw new TypeError(`${what} must be an object`);
114
+ return value;
115
+ }
116
+
117
+ export function createCheckContext(options: ContextOptions): CheckContext {
118
+ const config = deepFreeze(options.config);
119
+ const http = createHttp(defaultBaseUrl(options.descriptor, config));
120
+ const ctx = {
121
+ config,
122
+ secrets: options.secrets,
123
+ http,
124
+ paginate: createPaginate(http),
125
+ db: createDb(),
126
+ map: createMap(mapConcurrency(options)),
127
+ cache: createCache(),
128
+ crypto: createCrypto(),
129
+ time: createTime(),
130
+ log: options.logs.logger(),
131
+ signal: createSignal(),
132
+ };
133
+ Object.defineProperty(ctx, 'files', {
134
+ get() {
135
+ throw new errors.NotAvailable('ctx.files');
136
+ },
137
+ });
138
+ return Object.freeze(ctx) as unknown as CheckContext;
139
+ }
140
+
141
+ /** 拉取的 `ctx`:`record` / `remove` / `checkpoint` 只产出标记,没有副作用;只有 `yield` 出来的标记算数。 */
142
+ export function createPullContext(options: ContextOptions): PullContext {
143
+ const base = createCheckContext(options);
144
+ const ctx = Object.create(null) as Record<string, unknown>;
145
+ for (const key of Object.keys(base)) ctx[key] = (base as unknown as Record<string, unknown>)[key];
146
+ Object.defineProperty(ctx, 'files', {
147
+ get() {
148
+ throw new errors.NotAvailable('ctx.files');
149
+ },
150
+ });
151
+ ctx.record = (table: string, row: Record<string, unknown>): RecordItem =>
152
+ Object.freeze({ __ontos: 'record', table: tableName(table), row: plainObject(row, 'row') });
153
+ ctx.remove = (table: string, key: Record<string, unknown>): RemoveItem =>
154
+ Object.freeze({ __ontos: 'remove', table: tableName(table), key: plainObject(key, 'key') });
155
+ ctx.checkpoint = (state: unknown): CheckpointItem => Object.freeze({ __ontos: 'checkpoint', state });
156
+ return Object.freeze(ctx) as unknown as PullContext;
157
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `ctx.db`:经宿主的数据库代理执行只读查询。`db.query` 回第一批行与游标,未完时逐批 `db.next`;
3
+ * 迭代中途退出(`break` / 抛错)时发 `db.close` 释放游标。参数在这里规整成 JSON 值(日期转 ISO 文本,bigint 转字符串)。
4
+ */
5
+ import type { ConnectorDb } from '../../types';
6
+ import type { DbNextResponse, DbQueryResponse } from '../protocol';
7
+ import { call, isAborted, throwIfAborted } from './bridge';
8
+
9
+ function isRecord(value: unknown): value is Record<string, unknown> {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ function param(value: unknown): unknown {
14
+ if (value === undefined) return null;
15
+ if (typeof value === 'bigint') return value.toString();
16
+ if (value instanceof Date) return value.toISOString();
17
+ return value;
18
+ }
19
+
20
+ function rowsOf(value: unknown): Array<Record<string, unknown>> {
21
+ if (!isRecord(value) || !Array.isArray(value.rows) || typeof value.done !== 'boolean') throw new TypeError('db host call returned a malformed response');
22
+ return value.rows as Array<Record<string, unknown>>;
23
+ }
24
+
25
+ async function* run<R>(sql: string, params: unknown[]): AsyncGenerator<R> {
26
+ throwIfAborted();
27
+ const first = await call('db.query', { sql, params: params.map(param) }) as DbQueryResponse;
28
+ let rows = rowsOf(first);
29
+ let done = first.done;
30
+ const cursorId = typeof first.cursorId === 'string' ? first.cursorId : null;
31
+ if (!done && cursorId === null) throw new TypeError('db host call returned more rows without a cursor');
32
+ try {
33
+ for (;;) {
34
+ for (const row of rows) yield row as R;
35
+ if (done || cursorId === null) return;
36
+ throwIfAborted();
37
+ const next = await call('db.next', { cursorId }) as DbNextResponse;
38
+ rows = rowsOf(next);
39
+ done = next.done;
40
+ }
41
+ } finally {
42
+ if (!done && cursorId !== null) {
43
+ const closing = call('db.close', { cursorId });
44
+ // 取消之后父进程不再保证回复,不等它。
45
+ if (isAborted()) closing.catch(() => undefined);
46
+ else await closing;
47
+ }
48
+ }
49
+ }
50
+
51
+ export function createDb(): ConnectorDb {
52
+ return Object.freeze({
53
+ query<R = Record<string, unknown>>(sql: string, params: unknown[] = []): AsyncIterable<R> {
54
+ if (typeof sql !== 'string' || sql.trim().length === 0) throw new TypeError('ctx.db.query expects a SQL string');
55
+ if (!Array.isArray(params)) throw new TypeError('ctx.db.query params must be an array');
56
+ return run<R>(sql, [...params]);
57
+ },
58
+ });
59
+ }