@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,557 @@
1
+ /**
2
+ * guest 运行时与宿主(runner 执行子进程 / 父进程)之间的消息契约。
3
+ *
4
+ * 消息只用 JSON 兼容结构。子进程起来后发 `ready`,父进程回一条 `execute`;之后子进程按需发 `call`
5
+ * (宿主调用),父进程逐条回 `reply`;最后子进程发 `result` 或 `failure`。父进程随时可以发 `cancel`。
6
+ *
7
+ * 宿主调用的 payload 与 shared 的出站网关线协议(`EgressHttpRequest` 等)结构一致;SDK 不依赖 shared,
8
+ * 这里自己定义一份同形的类型。子进程里跑的是不可信代码,父进程用 `parseChildMessage` 逐字段重建消息,
9
+ * 不直接信任子进程产出的结构。
10
+ */
11
+ import type { ColumnType, SuggestedColumn } from '../descriptor';
12
+
13
+ export type HostMethod = 'http' | 'db.query' | 'db.next' | 'db.close' | 'crypto' | 'cache.get' | 'cache.set' | 'secret.reveal' | 'emit';
14
+
15
+ export const HOST_METHODS: readonly HostMethod[] = Object.freeze([
16
+ 'http', 'db.query', 'db.next', 'db.close', 'crypto', 'cache.get', 'cache.set', 'secret.reveal', 'emit',
17
+ ]);
18
+
19
+ /**
20
+ * 本次运行的生效限流(连接器描述与连接覆盖取小),由宿主放进作业;guest 用 `concurrency` 收紧 `ctx.map` 的并发。
21
+ * 不给时只按描述的 `rateLimit`。
22
+ */
23
+ export interface JobRateLimit { perSecond: number; concurrency: number }
24
+
25
+ export interface GuestPushRequest {
26
+ requestId: string;
27
+ push: string;
28
+ receivedAt: string;
29
+ headers: Record<string, string>;
30
+ body: string;
31
+ }
32
+
33
+ export type GuestOperationPhase = 'run' | 'lookup' | 'simulate';
34
+
35
+ /**
36
+ * 连接器操作作业:`phase` 为 `run` 时调 `run`,`lookup` 时调 `lookup`,`simulate` 时调 `simulate`(纯函数,期间不允许任何宿主调用)。
37
+ * `idempotencyKey` 只透传给 `run` / `lookup`。
38
+ */
39
+ export type GuestOperationJob = {
40
+ kind: 'operation'; phase: GuestOperationPhase;
41
+ config: Record<string, unknown>; secretFields: string[];
42
+ operation: string; params: Record<string, unknown>; idempotencyKey: string;
43
+ rateLimit?: JobRateLimit;
44
+ };
45
+
46
+ /**
47
+ * 作业(guest 在执行任何作业前按连接器描述核对实现,不一致时以 `CONNECTOR_LOAD_FAILED` 失败):
48
+ * - `operation`:见 `GuestOperationJob`,结果总是 `GuestOperationResult`(失败也走 `result`,只有加载失败走 `failure`);
49
+ * - `check`:只跑连接器的 `check`(没有时直接成功),结果 `{ kind: 'check'; ok: true; logs }`;
50
+ * - `pull`:`tables` 是该数据流在本次运行生效的表(宿主取描述里该数据流的表),`emit` 的表必须在其中;
51
+ * - `suggest`:调该数据流的 `suggestColumns(ctx, { table })`,结果 `{ kind: 'suggest'; columns; logs }`,
52
+ * 返回值不合法时以 `SUGGEST_INVALID` 失败;`table` 必须是该数据流的可扩展表。
53
+ */
54
+ export type GuestJob =
55
+ | { kind: 'push'; config: Record<string, unknown>; secretFields: string[]; requests: GuestPushRequest[] }
56
+ | { kind: 'check'; config: Record<string, unknown>; secretFields: string[]; rateLimit?: JobRateLimit }
57
+ | {
58
+ kind: 'pull'; config: Record<string, unknown>; secretFields: string[];
59
+ stream: string; mode: 'full' | 'incremental'; state: unknown; streamConfig: Record<string, unknown>; tables: string[];
60
+ rateLimit?: JobRateLimit;
61
+ }
62
+ | { kind: 'suggest'; config: Record<string, unknown>; secretFields: string[]; stream: string; table: string; rateLimit?: JobRateLimit }
63
+ | GuestOperationJob;
64
+
65
+ export interface HostCallError {
66
+ code: string;
67
+ message: string;
68
+ status?: number;
69
+ retryAfterMs?: number;
70
+ }
71
+
72
+ export type ParentMessage =
73
+ | { type: 'execute'; guest: string; code: string; expect: { name: string; version: string }; job: GuestJob }
74
+ | { type: 'reply'; id: number; ok: true; value: unknown }
75
+ | { type: 'reply'; id: number; ok: false; error: HostCallError }
76
+ | { type: 'cancel' };
77
+
78
+ export interface GuestLog {
79
+ at: string;
80
+ level: 'info' | 'warn' | 'error';
81
+ message: string;
82
+ requestId?: string;
83
+ }
84
+
85
+ /** 与 shared 的 `RunnerPushResult` 同形。 */
86
+ export interface GuestPushResult {
87
+ kind: 'push';
88
+ records: Array<{ requestId: string; table: string; row: Record<string, unknown> }>;
89
+ removals: Array<{ requestId: string; table: string; key: Record<string, unknown> }>;
90
+ rejected: Array<{ requestId: string; code: string; message: string }>;
91
+ logs: GuestLog[];
92
+ }
93
+
94
+ /** `notImplemented` 只在操作没有实现 `lookup` 时出现。 */
95
+ export type LookupAnswer = { status: 'applied'; output: Record<string, unknown> } | { status: 'not_applied' } | { status: 'unknown'; notImplemented?: true };
96
+
97
+ /** 连接器操作失败的码:外部系统拒绝、结果不明、guest 通用失败码,以及入参 / 输出不符合声明。 */
98
+ export type GuestOperationFailureCode = 'REJECTED' | 'INDETERMINATE' | GuestFailureCode | 'INPUT_INVALID' | 'OUTPUT_INVALID';
99
+
100
+ /**
101
+ * 连接器操作的结果。`ok: false` 时 `external` 只在 `REJECTED` 时出现(外部系统的业务码与文案)。
102
+ * `simulate` 阶段没有声明 `simulate` 时为 `ok: false, code: 'FATAL_ERROR'`,宿主据此回退缺省结果。
103
+ */
104
+ export type GuestOperationResult = (
105
+ | { kind: 'operation'; phase: 'run'; ok: true; output: Record<string, unknown> }
106
+ | { kind: 'operation'; phase: 'lookup'; ok: true; lookup: LookupAnswer }
107
+ | { kind: 'operation'; phase: 'simulate'; ok: true; output: Record<string, unknown> }
108
+ | { kind: 'operation'; phase: GuestOperationPhase; ok: false; code: GuestOperationFailureCode; message: string; external?: { code?: string; message: string } }
109
+ ) & { logs: GuestLog[] };
110
+
111
+ /** `pull` 的 `stopped: true`:某次 `emit` 的回复带 `{ stop: true }`,guest 触发 `ctx.signal` 后正常结束(不是 `CANCELED`)。 */
112
+ export type GuestResult =
113
+ | GuestPushResult
114
+ | { kind: 'check'; ok: true; logs: GuestLog[] }
115
+ | { kind: 'suggest'; columns: SuggestedColumn[]; logs: GuestLog[] }
116
+ | { kind: 'pull'; emittedBatches: number; stopped?: boolean; logs: GuestLog[] }
117
+ | GuestOperationResult;
118
+
119
+ /**
120
+ * - `CONNECTOR_LOOKUP_REQUIRED`:加载时发现 `idempotency: unsupported` 的连接器操作没有实现 `lookup`;
121
+ * - `SUGGEST_INVALID`:`suggestColumns` 返回了不合法的列(列名、类型或结构)。
122
+ */
123
+ export type GuestFailureCode =
124
+ | 'CONNECTOR_LOAD_FAILED' | 'CONNECTOR_LOOKUP_REQUIRED' | 'CONFIG_INVALID' | 'SCHEMA_ERROR' | 'FATAL_ERROR' | 'AUTH_ERROR' | 'TRANSIENT_ERROR' | 'PUSH_UNKNOWN'
125
+ | 'SUGGEST_INVALID' | 'CANCELED';
126
+
127
+ export const GUEST_FAILURE_CODES: readonly GuestFailureCode[] = Object.freeze([
128
+ 'CONNECTOR_LOAD_FAILED', 'CONNECTOR_LOOKUP_REQUIRED', 'CONFIG_INVALID', 'SCHEMA_ERROR', 'FATAL_ERROR', 'AUTH_ERROR', 'TRANSIENT_ERROR', 'PUSH_UNKNOWN',
129
+ 'SUGGEST_INVALID', 'CANCELED',
130
+ ]);
131
+
132
+ export const GUEST_OPERATION_FAILURE_CODES: readonly GuestOperationFailureCode[] = Object.freeze([
133
+ 'REJECTED', 'INDETERMINATE', ...GUEST_FAILURE_CODES, 'INPUT_INVALID', 'OUTPUT_INVALID',
134
+ ]);
135
+
136
+ export type ChildMessage =
137
+ | { type: 'ready' }
138
+ | { type: 'call'; id: number; method: HostMethod; payload: unknown }
139
+ | { type: 'result'; result: GuestResult }
140
+ | { type: 'failure'; code: GuestFailureCode; message: string; stack?: string; logs: GuestLog[] };
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // 宿主调用的 payload 与回复(与 shared 的出站网关线协议同形)
144
+
145
+ export interface EgressHttpRequest {
146
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
147
+ url: string;
148
+ headers: Record<string, string>;
149
+ query: Record<string, string | string[]>;
150
+ body: string | null;
151
+ bodyEncoding: 'utf8' | 'base64';
152
+ timeoutMs?: number;
153
+ }
154
+ export interface EgressHttpResponse { status: number; url: string; headers: Record<string, string>; body: string; bodyEncoding: 'utf8' | 'base64' }
155
+ export interface DbQueryRequest { sql: string; params: unknown[] }
156
+ export interface DbQueryResponse { cursorId: string | null; fields: Array<{ name: string; dataTypeId: number }>; rows: Array<Record<string, unknown>>; done: boolean }
157
+ export interface DbCursorRequest { cursorId: string }
158
+ export interface DbNextResponse { rows: Array<Record<string, unknown>>; done: boolean }
159
+ export type CryptoRequest =
160
+ | { op: 'hmac'; algorithm: 'sha1' | 'sha256' | 'sha512'; key: string; data: string; encoding: 'hex' | 'base64' }
161
+ | { op: 'sign'; algorithm: 'rsa-sha256'; key: string; data: string; encoding: 'hex' | 'base64' }
162
+ | { op: 'hash'; algorithm: 'md5' | 'sha1' | 'sha256' | 'sha512'; data: string; encoding: 'hex' | 'base64' };
163
+ export interface CryptoResponse { value: string }
164
+ export interface CacheGetRequest { key: string }
165
+ export interface CacheEntry { value: string; expiresAt: string }
166
+ export interface CacheSetRequest { key: string; value: string; ttlSeconds: number }
167
+ export interface SecretRevealRequest { field: string }
168
+ export interface SecretRevealResponse { value: string }
169
+ /** `emit` 宿主调用的回复:`stop: true` 时 guest 不再发 `emit`,以 `stopped: true` 结束。其余字段(seq 等)guest 不看。 */
170
+ export interface EmitReply {
171
+ stop?: boolean;
172
+ }
173
+
174
+ /** `emit` 的 payload:与 shared 的 `RunnerPullBatch` 去掉 `seq` 后同形;`seq` 由宿主编号。 */
175
+ export interface GuestPullBatch {
176
+ records: Array<{ table: string; row: Record<string, unknown> }>;
177
+ removals: Array<{ table: string; key: Record<string, unknown> }>;
178
+ checkpoint?: { state: unknown };
179
+ logs: GuestLog[];
180
+ /** 自上一批以来丢弃的超大记录 / 删除标记条数;为 0 时不带。 */
181
+ dropped?: number;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // 解析
186
+
187
+ const HTTP_METHODS: ReadonlySet<string> = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']);
188
+ const BODY_ENCODINGS: ReadonlySet<string> = new Set(['utf8', 'base64']);
189
+ const DIGEST_ENCODINGS: ReadonlySet<string> = new Set(['hex', 'base64']);
190
+ const HMAC_ALGORITHMS: ReadonlySet<string> = new Set(['sha1', 'sha256', 'sha512']);
191
+ const HASH_ALGORITHMS: ReadonlySet<string> = new Set(['md5', 'sha1', 'sha256', 'sha512']);
192
+ const LOG_LEVELS: ReadonlySet<string> = new Set(['info', 'warn', 'error']);
193
+ const SYNC_MODES: ReadonlySet<string> = new Set(['full', 'incremental']);
194
+ const COLUMN_TYPES: ReadonlySet<string> = new Set<ColumnType>(['string', 'integer', 'decimal', 'boolean', 'date', 'instant', 'json']);
195
+ const HOST_METHOD_SET: ReadonlySet<string> = new Set(HOST_METHODS);
196
+ const FAILURE_CODE_SET: ReadonlySet<string> = new Set(GUEST_FAILURE_CODES);
197
+ const OPERATION_PHASES: ReadonlySet<string> = new Set(['run', 'lookup', 'simulate']);
198
+ const OPERATION_FAILURE_CODE_SET: ReadonlySet<string> = new Set(GUEST_OPERATION_FAILURE_CODES);
199
+
200
+ function isRecord(value: unknown): value is Record<string, unknown> {
201
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
202
+ }
203
+
204
+ function isStringArray(value: unknown): value is string[] {
205
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
206
+ }
207
+
208
+ function isStringRecord(value: unknown): value is Record<string, string> {
209
+ return isRecord(value) && Object.values(value).every((item) => typeof item === 'string');
210
+ }
211
+
212
+ function isId(value: unknown): value is number {
213
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
214
+ }
215
+
216
+ function parsePushRequest(value: unknown): GuestPushRequest | null {
217
+ if (!isRecord(value)) return null;
218
+ const { requestId, push, receivedAt, headers, body } = value;
219
+ if (typeof requestId !== 'string' || typeof push !== 'string' || typeof receivedAt !== 'string' || !isStringRecord(headers) || typeof body !== 'string') return null;
220
+ return { requestId, push, receivedAt, headers: { ...headers }, body };
221
+ }
222
+
223
+ /** 作业里的生效限流:不给为 undefined;给了但不合法为 null。 */
224
+ function parseRateLimit(value: unknown): JobRateLimit | undefined | null {
225
+ if (value === undefined) return undefined;
226
+ if (!isRecord(value)) return null;
227
+ const { perSecond, concurrency } = value;
228
+ if (typeof perSecond !== 'number' || !Number.isFinite(perSecond) || perSecond <= 0) return null;
229
+ if (typeof concurrency !== 'number' || !Number.isSafeInteger(concurrency) || concurrency < 1) return null;
230
+ return { perSecond, concurrency };
231
+ }
232
+
233
+ function parseJob(value: unknown): GuestJob | null {
234
+ if (!isRecord(value) || !isRecord(value.config) || !isStringArray(value.secretFields)) return null;
235
+ const config = value.config;
236
+ const secretFields = [...value.secretFields];
237
+ if (value.kind === 'push') {
238
+ if (!Array.isArray(value.requests)) return null;
239
+ const requests: GuestPushRequest[] = [];
240
+ for (const item of value.requests) {
241
+ const request = parsePushRequest(item);
242
+ if (!request) return null;
243
+ requests.push(request);
244
+ }
245
+ return { kind: 'push', config, secretFields, requests };
246
+ }
247
+ const rateLimit = parseRateLimit(value.rateLimit);
248
+ if (rateLimit === null) return null;
249
+ const limit = rateLimit ? { rateLimit } : {};
250
+ if (value.kind === 'check') return { kind: 'check', config, secretFields, ...limit };
251
+ if (value.kind === 'pull') {
252
+ const { stream, mode, streamConfig, tables } = value;
253
+ if (typeof stream !== 'string' || typeof mode !== 'string' || !SYNC_MODES.has(mode) || !isRecord(streamConfig) || !isStringArray(tables)) return null;
254
+ return { kind: 'pull', config, secretFields, stream, mode: mode as 'full' | 'incremental', state: value.state, streamConfig, tables: [...tables], ...limit };
255
+ }
256
+ if (value.kind === 'suggest') {
257
+ const { stream, table } = value;
258
+ if (typeof stream !== 'string' || typeof table !== 'string') return null;
259
+ return { kind: 'suggest', config, secretFields, stream, table, ...limit };
260
+ }
261
+ if (value.kind === 'operation') {
262
+ const { phase, operation, params, idempotencyKey } = value;
263
+ if (typeof phase !== 'string' || !OPERATION_PHASES.has(phase) || typeof operation !== 'string' || !isRecord(params) || typeof idempotencyKey !== 'string') return null;
264
+ return { kind: 'operation', phase: phase as GuestOperationPhase, config, secretFields, operation, params, idempotencyKey, ...limit };
265
+ }
266
+ return null;
267
+ }
268
+
269
+ function parseHostError(value: unknown): HostCallError | null {
270
+ if (!isRecord(value) || typeof value.code !== 'string' || typeof value.message !== 'string') return null;
271
+ const error: HostCallError = { code: value.code, message: value.message };
272
+ if (value.status !== undefined) {
273
+ if (typeof value.status !== 'number' || !Number.isInteger(value.status)) return null;
274
+ error.status = value.status;
275
+ }
276
+ if (value.retryAfterMs !== undefined) {
277
+ if (typeof value.retryAfterMs !== 'number' || !Number.isFinite(value.retryAfterMs) || value.retryAfterMs < 0) return null;
278
+ error.retryAfterMs = value.retryAfterMs;
279
+ }
280
+ return error;
281
+ }
282
+
283
+ /** 解析父进程消息(子进程侧);结构不合法时返回 null。 */
284
+ export function parseParentMessage(value: unknown): ParentMessage | null {
285
+ if (!isRecord(value)) return null;
286
+ switch (value.type) {
287
+ case 'execute': {
288
+ const { guest, code, expect } = value;
289
+ if (typeof guest !== 'string' || typeof code !== 'string') return null;
290
+ if (!isRecord(expect) || typeof expect.name !== 'string' || typeof expect.version !== 'string') return null;
291
+ const job = parseJob(value.job);
292
+ return job ? { type: 'execute', guest, code, expect: { name: expect.name, version: expect.version }, job } : null;
293
+ }
294
+ case 'reply': {
295
+ if (!isId(value.id)) return null;
296
+ if (value.ok === true) return { type: 'reply', id: value.id, ok: true, value: value.value };
297
+ if (value.ok !== false) return null;
298
+ const error = parseHostError(value.error);
299
+ return error ? { type: 'reply', id: value.id, ok: false, error } : null;
300
+ }
301
+ case 'cancel':
302
+ return { type: 'cancel' };
303
+ default:
304
+ return null;
305
+ }
306
+ }
307
+
308
+ function parseLogs(value: unknown): GuestLog[] | null {
309
+ if (!Array.isArray(value)) return null;
310
+ const logs: GuestLog[] = [];
311
+ for (const entry of value) {
312
+ if (!isRecord(entry) || typeof entry.at !== 'string' || typeof entry.level !== 'string' || !LOG_LEVELS.has(entry.level) || typeof entry.message !== 'string') return null;
313
+ if (entry.requestId !== undefined && typeof entry.requestId !== 'string') return null;
314
+ logs.push({
315
+ at: entry.at,
316
+ level: entry.level as GuestLog['level'],
317
+ message: entry.message,
318
+ ...(entry.requestId !== undefined ? { requestId: entry.requestId } : {}),
319
+ });
320
+ }
321
+ return logs;
322
+ }
323
+
324
+ function parsePushResult(value: Record<string, unknown>, requestIds: ReadonlySet<string>): GuestPushResult | null {
325
+ const { records, removals, rejected } = value;
326
+ if (!Array.isArray(records) || !Array.isArray(removals) || !Array.isArray(rejected)) return null;
327
+ const logs = parseLogs(value.logs);
328
+ if (!logs) return null;
329
+ const result: GuestPushResult = { kind: 'push', records: [], removals: [], rejected: [], logs };
330
+ for (const item of records) {
331
+ if (!isRecord(item) || typeof item.requestId !== 'string' || !requestIds.has(item.requestId)) return null;
332
+ if (typeof item.table !== 'string' || !isRecord(item.row)) return null;
333
+ result.records.push({ requestId: item.requestId, table: item.table, row: item.row });
334
+ }
335
+ for (const item of removals) {
336
+ if (!isRecord(item) || typeof item.requestId !== 'string' || !requestIds.has(item.requestId)) return null;
337
+ if (typeof item.table !== 'string' || !isRecord(item.key)) return null;
338
+ result.removals.push({ requestId: item.requestId, table: item.table, key: item.key });
339
+ }
340
+ for (const item of rejected) {
341
+ if (!isRecord(item) || typeof item.requestId !== 'string' || !requestIds.has(item.requestId)) return null;
342
+ if (typeof item.code !== 'string' || typeof item.message !== 'string') return null;
343
+ result.rejected.push({ requestId: item.requestId, code: item.code, message: item.message });
344
+ }
345
+ return result;
346
+ }
347
+
348
+ function parseResult(value: unknown, job: GuestJob): GuestResult | null {
349
+ if (!isRecord(value) || value.kind !== job.kind) return null;
350
+ if (job.kind === 'push') return parsePushResult(value, new Set(job.requests.map((request) => request.requestId)));
351
+ const logs = parseLogs(value.logs);
352
+ if (!logs) return null;
353
+ if (job.kind === 'operation') return parseOperationResult(value, job, logs);
354
+ if (job.kind === 'suggest') {
355
+ const columns = parseSuggestedColumns(value.columns);
356
+ return columns ? { kind: 'suggest', columns, logs } : null;
357
+ }
358
+ if (job.kind === 'check') return value.ok === true ? { kind: 'check', ok: true, logs } : null;
359
+ const emittedBatches = value.emittedBatches;
360
+ if (typeof emittedBatches !== 'number' || !Number.isSafeInteger(emittedBatches) || emittedBatches < 0) return null;
361
+ if (value.stopped !== undefined && typeof value.stopped !== 'boolean') return null;
362
+ return { kind: 'pull', emittedBatches, ...(value.stopped === true ? { stopped: true } : {}), logs };
363
+ }
364
+
365
+ function parseExternal(value: unknown): { code?: string; message: string } | null {
366
+ if (!isRecord(value) || typeof value.message !== 'string') return null;
367
+ if (value.code !== undefined && typeof value.code !== 'string') return null;
368
+ return { message: value.message, ...(value.code !== undefined ? { code: value.code } : {}) };
369
+ }
370
+
371
+ function parseLookupAnswer(value: unknown): LookupAnswer | null {
372
+ if (!isRecord(value)) return null;
373
+ if (value.status === 'applied') return isRecord(value.output) ? { status: 'applied', output: value.output } : null;
374
+ if (value.status === 'not_applied') return { status: 'not_applied' };
375
+ if (value.status === 'unknown') return value.notImplemented === true ? { status: 'unknown', notImplemented: true } : { status: 'unknown' };
376
+ return null;
377
+ }
378
+
379
+ /** 操作结果的阶段必须与作业一致。 */
380
+ function parseOperationResult(value: Record<string, unknown>, job: GuestOperationJob, logs: GuestLog[]): GuestOperationResult | null {
381
+ if (value.phase !== job.phase) return null;
382
+ const phase = job.phase;
383
+ if (value.ok === false) {
384
+ if (typeof value.code !== 'string' || !OPERATION_FAILURE_CODE_SET.has(value.code) || typeof value.message !== 'string') return null;
385
+ let external: { code?: string; message: string } | undefined;
386
+ if (value.external !== undefined) {
387
+ const parsed = parseExternal(value.external);
388
+ if (!parsed) return null;
389
+ external = parsed;
390
+ }
391
+ return { kind: 'operation', phase, ok: false, code: value.code as GuestOperationFailureCode, message: value.message, ...(external ? { external } : {}), logs };
392
+ }
393
+ if (value.ok !== true) return null;
394
+ if (phase === 'lookup') {
395
+ const lookup = parseLookupAnswer(value.lookup);
396
+ return lookup ? { kind: 'operation', phase, ok: true, lookup, logs } : null;
397
+ }
398
+ if (!isRecord(value.output)) return null;
399
+ return phase === 'run'
400
+ ? { kind: 'operation', phase: 'run', ok: true, output: value.output, logs }
401
+ : { kind: 'operation', phase: 'simulate', ok: true, output: value.output, logs };
402
+ }
403
+
404
+ /** 只做结构重建;列名与类型的规则 guest 已按 `validateSuggestedColumns` 检查过,宿主写库前另按 shared 的规则再校验。 */
405
+ function parseSuggestedColumns(value: unknown): SuggestedColumn[] | null {
406
+ if (!Array.isArray(value)) return null;
407
+ const columns: SuggestedColumn[] = [];
408
+ for (const item of value) {
409
+ if (!isRecord(item) || typeof item.name !== 'string' || typeof item.type !== 'string' || !COLUMN_TYPES.has(item.type) || typeof item.title !== 'string') return null;
410
+ const { precision, scale, description } = item;
411
+ if (precision !== undefined && (typeof precision !== 'number' || !Number.isSafeInteger(precision))) return null;
412
+ if (scale !== undefined && (typeof scale !== 'number' || !Number.isSafeInteger(scale))) return null;
413
+ if (description !== undefined && typeof description !== 'string') return null;
414
+ columns.push({
415
+ name: item.name, type: item.type as ColumnType, title: item.title,
416
+ ...(precision !== undefined ? { precision } : {}), ...(scale !== undefined ? { scale } : {}), ...(description !== undefined ? { description } : {}),
417
+ });
418
+ }
419
+ return columns;
420
+ }
421
+
422
+ function parseBatch(value: unknown, tables: ReadonlySet<string>): GuestPullBatch | null {
423
+ if (!isRecord(value) || !Array.isArray(value.records) || !Array.isArray(value.removals)) return null;
424
+ const logs = parseLogs(value.logs);
425
+ if (!logs) return null;
426
+ const batch: GuestPullBatch = { records: [], removals: [], logs };
427
+ for (const item of value.records) {
428
+ if (!isRecord(item) || typeof item.table !== 'string' || !tables.has(item.table) || !isRecord(item.row)) return null;
429
+ batch.records.push({ table: item.table, row: item.row });
430
+ }
431
+ for (const item of value.removals) {
432
+ if (!isRecord(item) || typeof item.table !== 'string' || !tables.has(item.table) || !isRecord(item.key)) return null;
433
+ batch.removals.push({ table: item.table, key: item.key });
434
+ }
435
+ if (value.checkpoint !== undefined) {
436
+ if (!isRecord(value.checkpoint) || !Object.hasOwn(value.checkpoint, 'state')) return null;
437
+ batch.checkpoint = { state: value.checkpoint.state };
438
+ }
439
+ if (value.dropped !== undefined) {
440
+ if (typeof value.dropped !== 'number' || !Number.isSafeInteger(value.dropped) || value.dropped < 0) return null;
441
+ batch.dropped = value.dropped;
442
+ }
443
+ return batch;
444
+ }
445
+
446
+ function parseHttpRequest(value: unknown): EgressHttpRequest | null {
447
+ if (!isRecord(value)) return null;
448
+ const { method, url, headers, query, body, bodyEncoding, timeoutMs } = value;
449
+ if (typeof method !== 'string' || !HTTP_METHODS.has(method) || typeof url !== 'string' || !isStringRecord(headers)) return null;
450
+ if (!isRecord(query) || !Object.values(query).every((item) => typeof item === 'string' || isStringArray(item))) return null;
451
+ if (body !== null && typeof body !== 'string') return null;
452
+ if (typeof bodyEncoding !== 'string' || !BODY_ENCODINGS.has(bodyEncoding)) return null;
453
+ if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0)) return null;
454
+ const parsedQuery: Record<string, string | string[]> = {};
455
+ for (const [key, item] of Object.entries(query)) parsedQuery[key] = Array.isArray(item) ? [...(item as string[])] : item as string;
456
+ return {
457
+ method: method as EgressHttpRequest['method'], url, headers: { ...headers }, query: parsedQuery, body,
458
+ bodyEncoding: bodyEncoding as EgressHttpRequest['bodyEncoding'], ...(timeoutMs !== undefined ? { timeoutMs } : {}),
459
+ };
460
+ }
461
+
462
+ function parseCryptoRequest(value: unknown): CryptoRequest | null {
463
+ if (!isRecord(value) || typeof value.data !== 'string' || typeof value.algorithm !== 'string') return null;
464
+ if (typeof value.encoding !== 'string' || !DIGEST_ENCODINGS.has(value.encoding)) return null;
465
+ const { data, algorithm } = value;
466
+ const encoding = value.encoding as 'hex' | 'base64';
467
+ if (value.op === 'hash') {
468
+ return HASH_ALGORITHMS.has(algorithm) ? { op: 'hash', algorithm: algorithm as 'md5' | 'sha1' | 'sha256' | 'sha512', data, encoding } : null;
469
+ }
470
+ if (typeof value.key !== 'string') return null;
471
+ if (value.op === 'hmac') {
472
+ return HMAC_ALGORITHMS.has(algorithm) ? { op: 'hmac', algorithm: algorithm as 'sha1' | 'sha256' | 'sha512', key: value.key, data, encoding } : null;
473
+ }
474
+ if (value.op === 'sign') return algorithm === 'rsa-sha256' ? { op: 'sign', algorithm: 'rsa-sha256', key: value.key, data, encoding } : null;
475
+ return null;
476
+ }
477
+
478
+ /** 连接器操作作业能用的宿主方法:`simulate` 一个都不能用(纯函数),其余阶段没有 `db.*`、`emit`。 */
479
+ const OPERATION_HOST_METHODS: ReadonlySet<HostMethod> = new Set<HostMethod>(['http', 'crypto', 'cache.get', 'cache.set', 'secret.reveal']);
480
+
481
+ function parsePayload(method: HostMethod, payload: unknown, job: GuestJob): unknown {
482
+ if (job.kind === 'operation' && (job.phase === 'simulate' || !OPERATION_HOST_METHODS.has(method))) return null;
483
+ switch (method) {
484
+ case 'http':
485
+ return parseHttpRequest(payload);
486
+ case 'db.query':
487
+ return isRecord(payload) && typeof payload.sql === 'string' && Array.isArray(payload.params) ? { sql: payload.sql, params: [...payload.params] } : null;
488
+ case 'db.next':
489
+ case 'db.close':
490
+ return isRecord(payload) && typeof payload.cursorId === 'string' ? { cursorId: payload.cursorId } : null;
491
+ case 'crypto':
492
+ return parseCryptoRequest(payload);
493
+ case 'cache.get':
494
+ return isRecord(payload) && typeof payload.key === 'string' ? { key: payload.key } : null;
495
+ case 'cache.set': {
496
+ if (!isRecord(payload) || typeof payload.key !== 'string' || typeof payload.value !== 'string') return null;
497
+ const ttl = payload.ttlSeconds;
498
+ return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0 ? { key: payload.key, value: payload.value, ttlSeconds: ttl } : null;
499
+ }
500
+ case 'secret.reveal':
501
+ return isRecord(payload) && typeof payload.field === 'string' && job.secretFields.includes(payload.field) ? { field: payload.field } : null;
502
+ case 'emit':
503
+ return job.kind === 'pull' ? parseBatch(payload, new Set(job.tables)) : null;
504
+ }
505
+ }
506
+
507
+ /**
508
+ * `parseChildMessage` 拒绝一条 `emit` 时用来区分原因:记录或删除标记落在作业没声明的表上时返回第一张这样的表名,
509
+ * 宿主据此按 `SCHEMA_ERROR` 结束运行(与 guest 自己检查到时同一个错误码),而不是按子进程崩溃处理;其他情况返回 null。
510
+ */
511
+ export function undeclaredEmitTable(value: unknown, job: GuestJob): string | null {
512
+ if (job.kind !== 'pull' || !isRecord(value) || value.type !== 'call' || value.method !== 'emit' || !isRecord(value.payload)) return null;
513
+ const allowed = new Set(job.tables);
514
+ for (const key of ['records', 'removals'] as const) {
515
+ const items = value.payload[key];
516
+ if (!Array.isArray(items)) continue;
517
+ for (const item of items) {
518
+ if (isRecord(item) && typeof item.table === 'string' && !allowed.has(item.table)) return item.table;
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+
524
+ /**
525
+ * 解析子进程消息(父进程侧)并重建一份干净的对象;结构不合法时返回 null(父进程按崩溃处理)。
526
+ * 宿主调用的 payload 按方法校验:`emit` 只出现在 pull 作业且表必须在 `job.tables` 里,
527
+ * `secret.reveal` 的字段必须在 `job.secretFields` 里;结果的 `kind` 必须与作业一致。
528
+ */
529
+ export function parseChildMessage(value: unknown, job: GuestJob): ChildMessage | null {
530
+ if (!isRecord(value)) return null;
531
+ switch (value.type) {
532
+ case 'ready':
533
+ return { type: 'ready' };
534
+ case 'call': {
535
+ if (!isId(value.id) || typeof value.method !== 'string' || !HOST_METHOD_SET.has(value.method)) return null;
536
+ const method = value.method as HostMethod;
537
+ const payload = parsePayload(method, value.payload, job);
538
+ return payload === null ? null : { type: 'call', id: value.id, method, payload };
539
+ }
540
+ case 'result': {
541
+ const result = parseResult(value.result, job);
542
+ return result ? { type: 'result', result } : null;
543
+ }
544
+ case 'failure': {
545
+ if (typeof value.code !== 'string' || !FAILURE_CODE_SET.has(value.code) || typeof value.message !== 'string') return null;
546
+ if (value.stack !== undefined && typeof value.stack !== 'string') return null;
547
+ const logs = parseLogs(value.logs);
548
+ if (!logs) return null;
549
+ return {
550
+ type: 'failure', code: value.code as GuestFailureCode, message: value.message,
551
+ ...(value.stack !== undefined ? { stack: value.stack } : {}), logs,
552
+ };
553
+ }
554
+ default:
555
+ return null;
556
+ }
557
+ }