@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
package/README.md CHANGED
@@ -1,173 +1,192 @@
1
1
  # @deepexi/datasense-connector
2
2
 
3
- SDK for writing Datasense connectors, plus tools to run them on your own machine.
3
+ SDK for writing Datasense connectors, plus tools to run and test them on your own machine.
4
4
 
5
- ```sh
6
- npm install --save-dev @deepexi/datasense-connector
5
+ A connector is a source directory. Its metadata (config fields, streams and their tables, pushes, operations)
6
+ lives in YAML descriptor files; TypeScript files hold only the implementations:
7
+
8
+ ```
9
+ ontos-connector.yaml name, version, title, description, config, hosts, db, rateLimit
10
+ package.json { "dependencies": { "@deepexi/datasense-connector": "^3.0.0" } } and nothing else
11
+ check.ts required when there are config fields
12
+ lib/** optional shared code
13
+ streams/<name>/stream.yaml sync modes, params, tables (columns, primary key, extensible)
14
+ streams/<name>/index.ts export default defineStream('<name>', ...)
15
+ streams/<name>/suggest-columns.ts optional, only for streams with an extensible table
16
+ streams/<name>/tests/<case>/ test cases (case.json + exchanges.ndjson)
17
+ pushes/<name>/push.yaml + index.ts + tests/
18
+ operations/<name>/operation.yaml + index.ts + tests/
19
+ .ontos/ local only, never uploaded: descriptor.json and types.ts (written by codegen)
7
20
  ```
8
21
 
9
- A connector is a small TypeScript project: a `package.json` whose only dependency is
10
- `@deepexi/datasense-connector`, and an entry file (`main`, default `src/index.ts`) that default-exports
11
- `defineConnector(...)`. Upload the source directory with `ontos connector publish`; the platform bundles it and
12
- the connector runner executes it in an isolated VM context.
22
+ The platform parses and validates the YAML into one JSON descriptor (`ConnectorDescriptor`). The SDK never reads
23
+ YAML: at runtime it takes the descriptor embedded in the bundle, locally it reads `.ontos/descriptor.json`.
24
+ `ontos admin connector codegen` writes `.ontos/descriptor.json` and `.ontos/types.ts`; rerun it after every change
25
+ to a YAML file.
13
26
 
14
- ## `@deepexi/datasense-connector`
27
+ Connector code imports only `@deepexi/datasense-connector`, relative files and `.ontos/types`. Node or Bun
28
+ built-ins, other packages, subpaths of this package and import attributes are rejected when the source is bundled.
15
29
 
16
- - `defineConnector` and the `t.*` type builders (`t.secret({ exposeToScript })` for credential fields)
17
- - Pull connectors: `hosts`, `db`, `rateLimit`, `check(ctx)`, `discover(ctx)` returning streams and push tables,
18
- and `streams.<name>(ctx, input)` async generators yielding `ctx.record` / `ctx.remove` / `ctx.checkpoint`
19
- - Push connectors: named handlers `push: { <name>: { verify?, map } }`, for example `push: { default: { map } }`
20
- - `ConnectorError` and `errors.AuthError` / `RateLimited` / `TransientError` / `SchemaError` / `FatalError` /
21
- `RejectedPayload` / `NotAvailable`
22
- - `validateConnectorDefinition` / `validateDiscoverResult` / `validateConfig` / `summarizeDefinition`, shared with the platform
23
- - `SDK_VERSION` and `SUPPORTED_SDK_MAJORS`
30
+ ## `@deepexi/datasense-connector`
24
31
 
25
- Connector code imports only `@deepexi/datasense-connector` itself and relative files. Node or Bun built-ins,
26
- other packages, subpaths of this package and import attributes are rejected when the source is bundled.
32
+ - Authoring API, typed by the generated descriptor type `D`:
33
+ `defineStream`, `definePush`, `defineOperation`, `defineCheck`, `defineSuggestColumns`. Import the bound versions
34
+ from `.ontos/types` so table names, row types, `ctx.config`, `ctx.secrets`, operation input and output are checked.
35
+ - Descriptor types: `ConnectorDescriptor`, `TableDescriptor`, `ColumnDescriptor`, `ConfigField`, `ValueField`,
36
+ `StreamDescriptor`, `PushDescriptor`, `OperationDescriptor`, `SuggestedColumn`, `LocalizedText`.
37
+ - `DescriptorTypes<typeof literal>` derives `D` from a descriptor literal written `as const` (handy in tests).
38
+ - `validateConfig(fields, value)`, `validateOperationValue`, `validateSuggestedColumns`, shared with the platform.
39
+ - `ConnectorError` and the `errors` classes (the code is what the platform and the test cases see):
40
+
41
+ | Class | Code | Use |
42
+ | --- | --- | --- |
43
+ | `errors.AuthError(message)` | `AUTH_ERROR` | Bad or expired credentials. `ctx.http` throws it on a 401 |
44
+ | `errors.RateLimited(retryAfterMs, message?)` | `RATE_LIMITED` | The source asked to slow down; treated as transient |
45
+ | `errors.TransientError(message)` | `TRANSIENT_ERROR` | Retryable upstream trouble (502 / 503 / 504, timeouts) |
46
+ | `errors.SchemaError(message)` | `SCHEMA_ERROR` | The source's data no longer matches the contract |
47
+ | `errors.FatalError(message)` | `FATAL_ERROR` | Anything else that will not succeed on retry |
48
+ | `errors.RejectedPayload(message)` | `REJECTED_PAYLOAD` | Push: refuse one inbox request |
49
+ | `errors.RejectedError(message, { code? })` | `REJECTED` | Operation: the source refused the call and applied nothing |
50
+ | `errors.IndeterminateError(message)` | `INDETERMINATE` | Operation: you cannot tell whether the source applied the call |
51
+ | `errors.NotAvailable(feature)` | `NOT_AVAILABLE` | Thrown by reserved features such as `ctx.files` |
52
+
53
+ - `SDK_VERSION` and `SUPPORTED_SDK_MAJORS`.
27
54
 
28
- The `ctx` passed to `check`, `discover` and streams (`http`, `paginate`, `db`, `map`, `cache`, `crypto`, `time`,
29
- `log`, `signal`) is provided by the runtime. External write operations (`writes`) and `ctx.files` are not
30
- available yet.
55
+ ```typescript
56
+ // streams/orders/index.ts
57
+ import { defineStream } from '../../.ontos/types';
58
+
59
+ export default defineStream('orders', async function* (ctx, input) {
60
+ const since = (input.state as { since?: string } | undefined)?.since;
61
+ for await (const page of ctx.paginate.cursor({
62
+ request: (cursor) => ctx.http.get<{ items: Array<{ id: string; updated_at: string }>; next?: string }>(
63
+ `${ctx.config.baseUrl}/orders`,
64
+ { query: { cursor, since }, headers: { authorization: `Bearer ${ctx.secrets.apiKey}` } },
65
+ ),
66
+ rows: (res) => res.data.items,
67
+ nextCursor: (res) => res.data.next,
68
+ })) {
69
+ for (const row of page.rows) yield ctx.record('orders', row);
70
+ yield ctx.checkpoint({ since: page.maxOf((row) => row.updated_at) ?? since });
71
+ }
72
+ });
73
+ ```
31
74
 
32
- Pull semantics worth knowing:
75
+ The `ctx` (`http`, `paginate`, `db`, `map`, `cache`, `crypto`, `time`, `log`, `signal`, `record`, `remove`,
76
+ `checkpoint`) is provided by the runtime. `ctx.files` is reserved and throws `NOT_AVAILABLE`.
33
77
 
34
78
  - Records are sent in batches of at most 1000 records or 4 MiB. Yielding `ctx.checkpoint(state)` sends the
35
- pending batch at once, followed by the checkpoint, and the platform commits the checkpoint after those records
36
- are written. A failed run resumes from the last committed checkpoint, so only the records yielded after it
37
- are fetched again. Yield a checkpoint after each page.
38
- - `hosts` limits every outbound request, and `ctx.db` too. A database connector must list the database host,
39
- for example `hosts: ['{config.host}']`, or its queries are denied with `EGRESS_HOST_DENIED`.
79
+ pending batch at once, followed by the checkpoint, which the platform commits after those records are written.
80
+ A failed run resumes from the last committed checkpoint. Yield a checkpoint after each page.
81
+ - A record field that is not a declared column is dropped by the platform, unless the table is `extensible` and
82
+ the connection added that field as an extension column. `ontos admin connector test` fails such a case with
83
+ `UNDECLARED_FIELDS` for non-extensible tables.
84
+ - Outbound hosts are the descriptor's `hosts` plus the host of every `url` config value. Relative URLs in
85
+ `ctx.http` resolve against the first `url` config value.
40
86
  - Secrets are handles (`ontos:secret:<field>`) that the gateway replaces on the way out. Plaintext is only
41
- available for fields declared `t.secret({ exposeToScript: true })`, and reading them is logged on the run.
87
+ available for secret fields marked `exposeToScript`.
42
88
  - `ctx.http` waits and resends on gateway rate limits and upstream 429. Upstream 502 / 503 / 504 and gateway
43
- timeouts are retried with backoff only for idempotent methods (GET, HEAD, PUT, DELETE); pass `retry: true`
44
- to retry a POST / PATCH as well, or `retry: false` to never retry.
89
+ timeouts are retried with backoff only for idempotent methods; pass `retry: true` to retry a POST / PATCH.
90
+ - `ctx.map` runs at most `rateLimit.concurrency` calls at once (the smaller of the descriptor's and the
91
+ connection's limit).
92
+
93
+ ### Connector operations
94
+
95
+ An operation is one write call into the source. Its effect, risk, idempotency, input and output are declared in
96
+ `operation.yaml`; `index.ts` implements it:
45
97
 
46
98
  ```typescript
47
- import { defineConnector, t } from '@deepexi/datasense-connector';
48
-
49
- export default defineConnector({
50
- name: 'demo_pull',
51
- version: '1.0.0',
52
- config: t.object({ apiKey: t.secret(), region: t.string() }),
53
- hosts: ['{config.region}.api.example.com'],
54
- discover: () => ({
55
- streams: {
56
- orders: {
57
- tables: { order: { columns: { id: t.string(), updated_at: t.instant() }, primaryKey: ['id'] } },
58
- syncModes: ['full', 'incremental'],
59
- },
60
- },
61
- }),
62
- streams: {
63
- async *orders(ctx, { state }) {
64
- const since = (state as { since?: string } | undefined)?.since;
65
- for await (const page of ctx.paginate.cursor({
66
- request: (cursor) => ctx.http.get<{ items: Array<{ id: string; updated_at: string }>; next?: string }>(
67
- `https://${String(ctx.config.region)}.api.example.com/orders`,
68
- { query: { cursor, since }, headers: { authorization: `Bearer ${ctx.secrets.apiKey}` } },
69
- ),
70
- rows: (res) => res.data.items,
71
- nextCursor: (res) => res.data.next,
72
- })) {
73
- for (const row of page.rows) yield ctx.record('order', row);
74
- yield ctx.checkpoint({ since: page.maxOf((row) => row.updated_at) ?? since });
75
- }
76
- },
99
+ // operations/create_order/index.ts
100
+ import { errors } from '@deepexi/datasense-connector';
101
+ import { defineOperation } from '../../.ontos/types';
102
+
103
+ export default defineOperation('create_order', {
104
+ async run(ctx, params, { idempotencyKey }) {
105
+ const res = await ctx.http.post(`${ctx.config.baseUrl}/orders`, {
106
+ headers: { authorization: `Bearer ${ctx.secrets.token}`, 'idempotency-key': idempotencyKey },
107
+ body: params, raw: true, timeoutMs: 10_000,
108
+ });
109
+ const body = res.text ? (JSON.parse(res.text) as Record<string, unknown>) : {};
110
+ if (res.status === 422) throw new errors.RejectedError(String(body.message), typeof body.code === 'string' ? { code: body.code } : undefined);
111
+ if (res.status >= 400) throw new errors.FatalError(`upstream returned ${res.status}`);
112
+ return { externalId: String(body.id), status: String(body.status) };
77
113
  },
114
+ simulate: (params) => ({ externalId: `simulated-${params.shopCode}`, status: 'simulated' }),
78
115
  });
79
116
  ```
80
117
 
81
- ## `@deepexi/datasense-connector/testing`
118
+ - With `idempotency: unsupported`, `lookup(ctx, { idempotencyKey, params })` is required (a type error
119
+ otherwise); it answers `{ status: 'applied', output }`, `{ status: 'not_applied' }` or `{ status: 'unknown' }`.
120
+ - The platform checks `params` against `input` before every phase (`INPUT_INVALID`) and the result against
121
+ `output` (`OUTPUT_INVALID`).
122
+ - `simulate(params)` is a pure function used by dry runs and simulations.
123
+
124
+ ### Suggested columns
82
125
 
83
- Runs a connector locally, with a local gateway that answers its outbound HTTP, database, crypto, cache and
84
- secret calls the way the platform does. Use it from your tests (for example vitest). Connector source must not
85
- import this entry.
126
+ A stream with an extensible table may add `suggest-columns.ts`, which reads the fields the source offers so a
127
+ connection can pick extension columns:
86
128
 
87
129
  ```typescript
88
- import { expect, test } from 'vitest';
89
- import { createLocalGateway, runStream } from '@deepexi/datasense-connector/testing';
90
- import connector from '../src/index';
91
-
92
- test('reads the first page', async () => {
93
- const host = createLocalGateway({
94
- hosts: ['eu.api.example.com'],
95
- secrets: { apiKey: process.env.DEMO_API_KEY ?? '' },
96
- });
97
- try {
98
- const result = await runStream(connector, { stream: 'orders', config: { region: 'eu' }, limit: 50, host });
99
- expect(result.error).toBeNull();
100
- expect(result.records.length).toBeGreaterThan(0);
101
- } finally {
102
- await host.close();
103
- }
104
- });
130
+ import { defineSuggestColumns } from '../../.ontos/types';
131
+
132
+ export default defineSuggestColumns('customers', async (ctx, { table }) => [
133
+ { name: 'vip_level', type: 'string', title: 'VIP level' },
134
+ ]);
105
135
  ```
106
136
 
107
- - `runStream`, `runDiscover`, `runCheck` and `runPush` run an imported definition in the current process and
108
- return a `LocalRunResult`: `records`, `removals`, `checkpoints`, `logs`, `rejected`, `declaration`, `stopped`
109
- and `error` (`null` on success). `runStream` runs `check` and `discover` first and accepts records only for the
110
- tables the stream declares. With `limit`, the run stops after that many records and `stopped` is `true`.
111
- - `createLocalGateway(options)`:
112
- - `hosts`: the hosts the connector may reach (exact names or `*.suffix`). Anything else fails with
113
- `EGRESS_HOST_DENIED`. Cloud metadata addresses are always denied.
114
- - `secrets`: field to plaintext. Secret handles in URLs, headers, bodies and crypto keys are replaced on the
115
- way out, and plaintext in responses, logs and error messages is replaced with `***`.
116
- - `rateLimit: { perSecond, concurrency }`: requests are spaced and queued instead of failing.
117
- - `db`: connection settings for `ctx.db`. Queries run in a read-only transaction with cursor paging and
118
- need the optional peer dependency `postgres` (`npm install --save-dev postgres`); without it the run ends
119
- with `DB_DRIVER_MISSING`.
120
- - `fetch`: replaces the network in tests. `onExchange` receives every HTTP, database and crypto exchange.
121
- - `loadConnectorSource(dir, { typescript })` reads a connector project, checks its imports with the same rules
122
- as the platform and transpiles it with your TypeScript compiler (`import ts from 'typescript'`).
123
- `createVmExecutor(source).run(job, host, { limit, timeoutMs })` then runs it in a child process with an empty
124
- environment and the same VM context as the platform runner, and kills the process on timeout.
125
- `createInProcessExecutor(definition)` runs the same jobs without a child process.
126
-
127
- ### Fixtures
128
-
129
- A fixture is a recorded run that replays without the network. Each case lives in
130
- `fixtures/<case>/` (case names match `^[a-z0-9][a-z0-9_-]{0,63}$`):
131
-
132
- - `case.json`: `{ kind: 'pull', stream, mode, state?, streamConfig?, config, secrets?, limit?, expect? }`,
133
- `{ kind: 'push', push, config, secrets?, expect? }` or `{ kind: 'discover', config, secrets?, expect? }`.
134
- - `exchanges.ndjson`: one recorded HTTP, database or crypto exchange per line.
135
- - `requests.ndjson`: the inbox requests a push case replays (required for push cases).
137
+ Names must match `^[A-Za-z][A-Za-z0-9_]{0,62}$` and must not start with `_ontos_`; suggestions that repeat a
138
+ base column are dropped.
139
+
140
+ ## `@deepexi/datasense-connector/testing`
141
+
142
+ Runs a connector locally with a local gateway that answers its outbound HTTP, database, crypto, cache and secret
143
+ calls the way the platform does. Connector source must not import this entry.
136
144
 
137
145
  ```typescript
138
146
  import ts from 'typescript';
139
- import {
140
- createInProcessExecutor, createLocalGateway, createRecorder, readFixtureDir, runFixtures, runStream, serializeFixture,
141
- } from '@deepexi/datasense-connector/testing';
142
-
143
- // Record: attach a recorder to the local gateway, run, then write the files under fixtures/<case>/.
144
- const secrets = { apiKey: process.env.DEMO_API_KEY ?? '' };
145
- const recorder = createRecorder({ secrets });
146
- const host = createLocalGateway({ hosts: ['eu.api.example.com'], secrets, onExchange: recorder.onExchange });
147
- await runStream(connector, { stream: 'orders', config: { region: 'eu' }, limit: 20, host });
148
- const files = serializeFixture('orders-first-page', {
149
- case: { kind: 'pull', stream: 'orders', mode: 'full', config: { region: 'eu' }, secrets, limit: 20 },
150
- exchanges: recorder.exchanges(),
151
- });
147
+ import { loadLocalConnector, runTests } from '@deepexi/datasense-connector/testing';
152
148
 
153
- // Replay: every case must come back ok.
154
- const results = await runFixtures(createInProcessExecutor(connector), await readFixtureDir('.'));
149
+ const connector = await loadLocalConnector('.', { typescript: ts });
150
+ const report = await runTests(connector);
151
+ // report: { ok, cases: [{ capability, name, ok, failure }], covered, uncovered }
155
152
  ```
156
153
 
157
- - Recording replaces every secret value of 4 or more characters with `***`, as well as the values of credential
158
- headers (`authorization`, `cookie`, `set-cookie`, `x-api-key`, ...) and of query parameters that contain a
159
- secret handle. Handles such as `ontos:secret:apiKey` stay as they are. A database query read in several pages
160
- is recorded as one exchange holding every row.
161
- - During replay, an HTTP request matches a recorded one by method, URL (query sorted by key) and body; a request
162
- with no match fails with `FIXTURE_EXCHANGE_MISMATCH` and never reaches the network. `secret.reveal` answers
163
- from `case.secrets`, the cache is in memory, and `hash` is computed locally when it was not recorded.
164
- - `runFixture` / `runFixtures` return a `FixtureCaseResult` per case with `ok`, `findings`, `stats`,
165
- `declaration` and `error`. Pull cases run `check` and `discover` first; full-mode pull cases are replayed twice
166
- to check that the read is repeatable. Findings: `PRIMARY_KEY_DUPLICATE`, `RECORD_SCHEMA_MISMATCH`,
167
- `UNDECLARED_TABLE`, `CHECKPOINT_NOT_MONOTONIC`, `FULL_READ_NOT_REPEATABLE`, `FIXTURE_EXCHANGE_MISMATCH`,
168
- `EXPECTATION_FAILED` and `FIXTURE_EXCHANGE_UNUSED`. A case is `ok` when the run succeeds and the only findings
169
- are `FIXTURE_EXCHANGE_UNUSED`.
170
- - `checkCompliance` and `inferRecordSchema` are the same checks as plain functions.
154
+ - `loadLocalConnector(dir, { typescript })` reads `.ontos/descriptor.json` (missing: `DESCRIPTOR_NOT_GENERATED`,
155
+ run codegen first), checks imports with the platform's rules, transpiles the sources with your TypeScript and
156
+ assembles the same entry module as the platform. It also collects the test case files.
157
+ - `runTests(connector, { cases? })` replays every test case in a child process with an empty environment and the
158
+ platform runner's VM context. Every stream, push and operation needs at least one case; `uncovered` lists the
159
+ capabilities without one (such as `streams/customers`), and `ok` is false while any case fails or anything is
160
+ uncovered. Suggest cases run but do not count for coverage.
161
+ - `runStream`, `runCheck`, `runPush`, `runSuggestColumns` run an imported module `{ descriptor, implementations }`
162
+ in the current process and return a `LocalRunResult` (`records`, `removals`, `checkpoints`, `logs`, `rejected`,
163
+ `suggest`, `stopped`, `error`). `runOperation(executor, input)` runs one operation phase.
164
+ - `createLocalGateway({ hosts, secrets, rateLimit?, db?, fetch?, onExchange? })` is the local gateway; `db`
165
+ needs the optional peer dependency `postgres`.
166
+ - `createMockExternalSystem(routes)` is a scriptable fake of the source; pass its `fetch` to `createLocalGateway`.
167
+ - `createVmExecutor(connector)` / `createInProcessExecutor(module)` run guest jobs directly.
168
+
169
+ ### Test cases
170
+
171
+ A test case is a recorded run that replays without the network. It lives in the capability's directory,
172
+ `<streams|pushes|operations>/<name>/tests/<case>/` (case names match `^[a-z0-9][a-z0-9_-]{0,63}$`):
173
+
174
+ - `case.json`: the kind and the capability name follow from the directory.
175
+ - stream: `{ mode, state?, streamConfig?, config, secrets?, limit?, expect? }`
176
+ - push: `{ config, secrets?, expect? }`, plus `requests.ndjson` with the inbox requests to replay
177
+ - operation: `{ params, idempotencyKey, config, secrets?, expect: { ok: <output> } | { error: '<code>' } }`
178
+ - suggest (under a stream): `{ "kind": "suggest", table, config, secrets?, expect: { columns } }`
179
+ - `exchanges.ndjson`: one recorded HTTP, database or crypto exchange per line.
180
+
181
+ Recording (`createRecorder` attached to the local gateway, then `serializeFixture`) replaces secret values of 4 or
182
+ more characters, credential headers and query parameters holding a handle with `***`. During replay a request
183
+ with no recorded match fails with `FIXTURE_EXCHANGE_MISMATCH` and never reaches the network.
184
+
185
+ Findings: `UNDECLARED_FIELDS`, `PRIMARY_KEY_DUPLICATE`, `RECORD_SCHEMA_MISMATCH`, `UNDECLARED_TABLE`,
186
+ `CHECKPOINT_NOT_MONOTONIC`, `FULL_READ_NOT_REPEATABLE` (full-mode cases are replayed twice),
187
+ `FIXTURE_EXCHANGE_MISMATCH`, `EXPECTATION_FAILED` and `FIXTURE_EXCHANGE_UNUSED`. A case is ok when the run succeeds
188
+ and the only findings are `FIXTURE_EXCHANGE_UNUSED`. `checkCompliance` and `inferRecordSchema` are the same checks
189
+ as plain functions.
171
190
 
172
191
  The SDK itself has no runtime dependencies. The root entry does not import Node or Bun built-ins, so it can be
173
192
  bundled into a connector and run in an isolated VM context; `./testing` needs Node 20+ or Bun.
package/package.json CHANGED
@@ -1,35 +1,24 @@
1
1
  {
2
2
  "name": "@deepexi/datasense-connector",
3
- "version": "3.0.1",
4
- "description": "SDK for writing Datasense connectors: defineConnector, type builders, and local testing tools.",
5
- "license": "UNLICENSED",
3
+ "version": "3.1.0",
4
+ "module": "src/index.ts",
6
5
  "type": "module",
7
- "main": "./cjs/index.cjs",
8
- "module": "./index.js",
9
- "types": "./index.d.ts",
6
+ "private": false,
10
7
  "exports": {
11
- ".": {
12
- "types": "./index.d.ts",
13
- "import": "./index.js",
14
- "require": "./cjs/index.cjs"
15
- },
16
- "./testing": {
17
- "types": "./testing/index.d.ts",
18
- "import": "./testing/index.js",
19
- "require": "./cjs/testing.cjs"
20
- },
21
- "./runtime": {
22
- "types": "./runtime/index.d.ts",
23
- "import": "./runtime/index.js",
24
- "require": "./cjs/runtime.cjs"
25
- },
26
- "./package.json": "./package.json"
8
+ ".": "./src/index.ts",
9
+ "./testing": "./src/testing/index.ts",
10
+ "./runtime": "./src/runtime/index.ts"
27
11
  },
28
- "sideEffects": false,
29
- "engines": {
30
- "node": ">=20"
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "scripts": {
16
+ "build": "bun scripts/build.ts",
17
+ "test": "NODE_ENV=test bun test",
18
+ "lint": "eslint --fix",
19
+ "lint:check": "eslint . --max-warnings 0",
20
+ "ts-check": "tsc --noEmit"
31
21
  },
32
- "dependencies": {},
33
22
  "peerDependencies": {
34
23
  "postgres": "^3.4.9"
35
24
  },
@@ -38,7 +27,9 @@
38
27
  "optional": true
39
28
  }
40
29
  },
41
- "publishConfig": {
42
- "access": "public"
30
+ "devDependencies": {
31
+ "@mu-ontos/shared": "workspace:*",
32
+ "postgres": "^3.4.9",
33
+ "typescript": "^6.0.3"
43
34
  }
44
35
  }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * 构建可直接 `npm publish` 的发布目录(缺省 `dist/`,`--outdir <dir>` 可改,测试用):
3
+ *
4
+ * 1. `tsc -p tsconfig.build.json`:ESM `.js`(去掉注释)与 `.d.ts`(保留注释,给连接器作者看);之后把相对导入补成 `.js` / `/index.js`(Node 的 ESM 要求带扩展名);
5
+ * 2. Bun.build 另出 CJS 版本:`cjs/index.cjs`、`cjs/testing.cjs`、`cjs/runtime.cjs`(`src/internal/location.ts` 换成 `__dirname` 版本);
6
+ * 3. `guest.cjs`:guest 运行时连同 SDK 打成一个 CJS 串(参数与 shared 的 `bundleTrustedEntry` 相同:cjs、browser、无外部依赖、`macros: false`);
7
+ * 4. `testing/child.cjs`:`createVmExecutor` 的子进程入口(Node 可直接执行);
8
+ * 5. `package.json`(`exports` 的 `.`、`./testing`、`./runtime` 各有 `types` / `import` / `require`,`dependencies` 为空,
9
+ * `postgres` 是可选 peerDependency)与 `README.md`。
10
+ *
11
+ * 不改写工作区的 `package.json`;`postgres` 与 `typescript` 不打进产物。
12
+ */
13
+ import { existsSync } from 'node:fs';
14
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { bundleGuestWithBun } from '../src/runtime/guest-runtime';
17
+
18
+ const PACKAGE_DIR = path.resolve(import.meta.dir, '..');
19
+ const SRC = path.join(PACKAGE_DIR, 'src');
20
+
21
+ function parseOutdir(argv: readonly string[]): string {
22
+ const index = argv.indexOf('--outdir');
23
+ const value = index >= 0 ? argv[index + 1] : undefined;
24
+ return value ? path.resolve(value) : path.join(PACKAGE_DIR, 'dist');
25
+ }
26
+
27
+ const OUT = parseOutdir(process.argv.slice(2));
28
+
29
+ async function run(command: string[], cwd: string): Promise<void> {
30
+ const child = Bun.spawn(command, { cwd, stdout: 'inherit', stderr: 'inherit' });
31
+ const code = await child.exited;
32
+ if (code !== 0) throw new Error(`${command.join(' ')} exited with ${code}`);
33
+ }
34
+
35
+ async function listFiles(dir: string): Promise<string[]> {
36
+ const out: string[] = [];
37
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) out.push(...await listFiles(full));
40
+ else out.push(full);
41
+ }
42
+ return out;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // 1. ESM + 声明文件
47
+
48
+ const RELATIVE_SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.{1,2}(?:\/[^'"]*)?)\2/g;
49
+
50
+ /** 相对导入补扩展名:`./x` → `./x.js`(或 `./x/index.js`);已带扩展名的不动。 */
51
+ function rewriteSpecifiers(file: string, text: string): string {
52
+ const dir = path.dirname(file);
53
+ return text.replace(RELATIVE_SPECIFIER, (match, prefix: string, quote: string, specifier: string) => {
54
+ if (/\.(?:[cm]?js|json)$/.test(specifier)) return match;
55
+ const target = path.resolve(dir, specifier);
56
+ if (existsSync(`${target}.js`)) return `${prefix}${quote}${specifier}.js${quote}`;
57
+ if (existsSync(path.join(target, 'index.js'))) return `${prefix}${quote}${specifier.replace(/\/$/, '')}/index.js${quote}`;
58
+ throw new Error(`cannot resolve ${specifier} from ${path.relative(OUT, file)}`);
59
+ });
60
+ }
61
+
62
+ async function buildEsm(): Promise<void> {
63
+ // 源码注释多是平台内部的实现说明,不随包发布;第二遍只出声明,覆盖第一遍去掉了注释的 `.d.ts`。
64
+ await run([process.execPath, 'x', 'tsc', '-p', 'tsconfig.build.json', '--outDir', OUT, '--removeComments'], PACKAGE_DIR);
65
+ await run([process.execPath, 'x', 'tsc', '-p', 'tsconfig.build.json', '--outDir', OUT, '--emitDeclarationOnly'], PACKAGE_DIR);
66
+ for (const file of await listFiles(OUT)) {
67
+ if (!file.endsWith('.js') && !file.endsWith('.d.ts')) continue;
68
+ const text = await readFile(file, 'utf8');
69
+ const rewritten = rewriteSpecifiers(file, text);
70
+ if (rewritten !== text) await writeFile(file, rewritten);
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // 2–4. Bun.build 产物
76
+
77
+ /** CJS 版本里 `location.ts` 的替身:dist/cjs/*.cjs 的上一级就是 dist/。 */
78
+ const CJS_LOCATION = `import path from 'node:path';
79
+ export const IS_SOURCE = false;
80
+ export const PACKAGE_ROOT = path.resolve(__dirname, '..');
81
+ export function moduleFile(relative) { return path.join(PACKAGE_ROOT, relative + '.js'); }
82
+ `;
83
+
84
+ const LOCATION_FILE = path.join(SRC, 'internal', 'location.ts');
85
+ const CODE_FILE = /\.[cm]?[jt]sx?$/;
86
+
87
+ /** 源文件自己读(Bun 1.3+ 已 import 过的文件再交给 Bun.build 会报错);CJS 构建时替换 `location.ts`。 */
88
+ function sourcePlugin(replaceLocation: boolean): Bun.BunPlugin {
89
+ return {
90
+ name: 'datasense-connector-build',
91
+ setup(build) {
92
+ build.onLoad({ filter: CODE_FILE }, async (args) => {
93
+ if (replaceLocation && path.resolve(args.path) === LOCATION_FILE) return { contents: CJS_LOCATION, loader: 'js' };
94
+ return { contents: await Bun.file(args.path).text(), loader: args.path.endsWith('.tsx') ? 'tsx' : 'ts' };
95
+ });
96
+ },
97
+ };
98
+ }
99
+
100
+ const MACROS_OFF = { macros: false } as unknown as Partial<Parameters<typeof Bun.build>[0]>;
101
+
102
+ async function bundleCjs(entry: string, outFile: string): Promise<void> {
103
+ const result = await Bun.build({
104
+ entrypoints: [entry],
105
+ format: 'cjs',
106
+ target: 'node',
107
+ minify: false,
108
+ external: ['postgres', 'typescript'],
109
+ plugins: [sourcePlugin(true)],
110
+ throw: false,
111
+ ...MACROS_OFF,
112
+ });
113
+ const output = result.outputs.find((item) => item.kind === 'entry-point');
114
+ if (!result.success || !output) throw new Error(`bundle ${path.relative(PACKAGE_DIR, entry)} failed: ${result.logs.map((log) => log.message).join('; ')}`);
115
+ // Bun 把 `__dirname` / `__filename` 内联成构建机上的源文件路径(`var __dirname = "/…/src/internal";`);
116
+ // 去掉这些声明,让代码用 Node CommonJS 包装函数给的真实值。
117
+ const text = (await output.text()).replace(/^var (__dirname|__filename) = "[^"\n]*";\n/gm, '');
118
+ await mkdir(path.dirname(outFile), { recursive: true });
119
+ await writeFile(outFile, text);
120
+ }
121
+
122
+ /** 产物里不得出现构建机的源码路径。 */
123
+ async function assertNoBuildPaths(): Promise<void> {
124
+ for (const file of await listFiles(OUT)) {
125
+ if (!/\.(?:c?js|d\.ts)$/.test(file)) continue;
126
+ if ((await readFile(file, 'utf8')).includes(PACKAGE_DIR)) throw new Error(`${path.relative(OUT, file)} contains the build machine path ${PACKAGE_DIR}`);
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // 5. package.json 与 README
132
+
133
+ interface WorkspacePackage {
134
+ name: string;
135
+ version: string;
136
+ peerDependencies?: Record<string, string>;
137
+ peerDependenciesMeta?: Record<string, { optional?: boolean }>;
138
+ }
139
+
140
+ function exportEntry(base: string, cjs: string): Record<string, string> {
141
+ return { types: `./${base}.d.ts`, import: `./${base}.js`, require: `./cjs/${cjs}.cjs` };
142
+ }
143
+
144
+ async function writeManifest(): Promise<void> {
145
+ const workspace = JSON.parse(await readFile(path.join(PACKAGE_DIR, 'package.json'), 'utf8')) as WorkspacePackage;
146
+ const manifest = {
147
+ name: workspace.name,
148
+ version: workspace.version,
149
+ description: 'SDK for writing Datasense connectors: defineStream / definePush / defineOperation, the guest runtime, and local testing tools.',
150
+ license: 'UNLICENSED',
151
+ type: 'module',
152
+ main: './cjs/index.cjs',
153
+ module: './index.js',
154
+ types: './index.d.ts',
155
+ exports: {
156
+ '.': exportEntry('index', 'index'),
157
+ './testing': exportEntry('testing/index', 'testing'),
158
+ './runtime': exportEntry('runtime/index', 'runtime'),
159
+ './package.json': './package.json',
160
+ },
161
+ sideEffects: false,
162
+ engines: { node: '>=20' },
163
+ dependencies: {},
164
+ peerDependencies: workspace.peerDependencies ?? {},
165
+ peerDependenciesMeta: workspace.peerDependenciesMeta ?? {},
166
+ publishConfig: { access: 'public' },
167
+ };
168
+ await writeFile(path.join(OUT, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
169
+ await writeFile(path.join(OUT, 'README.md'), await readFile(path.join(PACKAGE_DIR, 'README.md'), 'utf8'));
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+
174
+ async function main(): Promise<void> {
175
+ await rm(OUT, { recursive: true, force: true });
176
+ await mkdir(OUT, { recursive: true });
177
+ await buildEsm();
178
+ await bundleCjs(path.join(SRC, 'index.ts'), path.join(OUT, 'cjs', 'index.cjs'));
179
+ await bundleCjs(path.join(SRC, 'testing', 'index.ts'), path.join(OUT, 'cjs', 'testing.cjs'));
180
+ await bundleCjs(path.join(SRC, 'runtime', 'index.ts'), path.join(OUT, 'cjs', 'runtime.cjs'));
181
+ await bundleCjs(path.join(SRC, 'testing', 'child.ts'), path.join(OUT, 'testing', 'child.cjs'));
182
+ await writeFile(path.join(OUT, 'guest.cjs'), await bundleGuestWithBun(path.join(SRC, 'runtime', 'guest', 'index.ts')));
183
+ await writeManifest();
184
+ await assertNoBuildPaths();
185
+ console.log(`built ${path.relative(process.cwd(), OUT) || OUT}`);
186
+ }
187
+
188
+ await main();