@deepexi/datasense-connector 0.0.0-stage → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -2
- package/package.json +33 -4
- package/scripts/build.ts +188 -0
- package/src/define.ts +267 -0
- package/src/descriptor.ts +116 -0
- package/src/errors.ts +105 -0
- package/src/index.ts +108 -0
- package/src/internal/location.ts +23 -0
- package/src/runtime/guest/bridge.ts +238 -0
- package/src/runtime/guest/cache.ts +43 -0
- package/src/runtime/guest/check.ts +17 -0
- package/src/runtime/guest/crypto.ts +166 -0
- package/src/runtime/guest/ctx.ts +157 -0
- package/src/runtime/guest/db.ts +59 -0
- package/src/runtime/guest/failure.ts +69 -0
- package/src/runtime/guest/http.ts +194 -0
- package/src/runtime/guest/index.ts +102 -0
- package/src/runtime/guest/limits.ts +20 -0
- package/src/runtime/guest/loader.ts +210 -0
- package/src/runtime/guest/log.ts +47 -0
- package/src/runtime/guest/map.ts +45 -0
- package/src/runtime/guest/operation.ts +193 -0
- package/src/runtime/guest/paginate.ts +169 -0
- package/src/runtime/guest/pull.ts +177 -0
- package/src/runtime/guest/push.ts +92 -0
- package/src/runtime/guest/signal.ts +14 -0
- package/src/runtime/guest/suggest.ts +51 -0
- package/src/runtime/guest/time.ts +137 -0
- package/src/runtime/guest-runtime.ts +75 -0
- package/src/runtime/index.ts +53 -0
- package/src/runtime/protocol.ts +557 -0
- package/src/runtime/session.ts +216 -0
- package/src/testing/child.ts +21 -0
- package/src/testing/compliance.ts +343 -0
- package/src/testing/engine.ts +445 -0
- package/src/testing/fixtures.ts +591 -0
- package/src/testing/gateway.ts +482 -0
- package/src/testing/import-guard.ts +221 -0
- package/src/testing/index.ts +43 -0
- package/src/testing/local-db.ts +226 -0
- package/src/testing/mock-external-system.ts +249 -0
- package/src/testing/pinned-http.ts +144 -0
- package/src/testing/recorder.ts +140 -0
- package/src/testing/redaction.ts +94 -0
- package/src/testing/replay.ts +437 -0
- package/src/testing/run-tests.ts +115 -0
- package/src/testing/schema.ts +70 -0
- package/src/testing/source-loader.ts +374 -0
- package/src/testing/types.ts +78 -0
- package/src/types.ts +329 -0
- package/src/validate.ts +267 -0
- package/test/build.test.ts +159 -0
- package/test/define.test-d.ts +182 -0
- package/test/fixtures/acceptance.ts +156 -0
- package/test/fixtures/connectors/http-pull/.ontos/descriptor.json +72 -0
- package/test/fixtures/connectors/http-pull/.ontos/types.ts +61 -0
- package/test/fixtures/connectors/http-pull/package.json +5 -0
- package/test/fixtures/connectors/http-pull/streams/items/index.ts +37 -0
- package/test/fixtures/connectors/http-pull/streams/items/map.ts +12 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/case.json +15 -0
- package/test/fixtures/connectors/http-pull/streams/items/tests/basic/exchanges.ndjson +2 -0
- package/test/fixtures/connectors/pg-pull/.ontos/descriptor.json +151 -0
- package/test/fixtures/connectors/pg-pull/.ontos/types.ts +62 -0
- package/test/fixtures/connectors/pg-pull/lib/sql.ts +14 -0
- package/test/fixtures/connectors/pg-pull/package.json +5 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/index.ts +30 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/case.json +15 -0
- package/test/fixtures/connectors/pg-pull/streams/customers/tests/customers-full/exchanges.ndjson +3 -0
- package/test/fixtures/connectors/pg-pull/streams/orders/index.ts +32 -0
- package/test/fixtures/descriptor.ts +54 -0
- package/test/fixtures/kingdee-example.ts +82 -0
- package/test/fixtures/record-acceptance.ts +32 -0
- package/test/runtime/ctx.test.ts +434 -0
- package/test/runtime/helpers.ts +157 -0
- package/test/runtime/jobs.test.ts +444 -0
- package/test/runtime/operation.test.ts +175 -0
- package/test/runtime/session.test.ts +229 -0
- package/test/testing/acceptance-fixtures.test.ts +69 -0
- package/test/testing/compliance.test.ts +247 -0
- package/test/testing/engine.test.ts +283 -0
- package/test/testing/fixtures.test.ts +221 -0
- package/test/testing/gateway.test.ts +337 -0
- package/test/testing/import-guard.test.ts +45 -0
- package/test/testing/mock-external-system.test.ts +144 -0
- package/test/testing/operation-fixtures.test.ts +160 -0
- package/test/testing/recorder.test.ts +163 -0
- package/test/testing/redaction.test.ts +54 -0
- package/test/testing/replay.test.ts +207 -0
- package/test/testing/run-tests.test.ts +137 -0
- package/test/testing/source-loader.test.ts +220 -0
- package/test/validate-config.test.ts +104 -0
- package/tsconfig.build.json +23 -0
- package/tsconfig.json +19 -0
package/README.md
CHANGED
|
@@ -1,3 +1,192 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @deepexi/datasense-connector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
SDK for writing Datasense connectors, plus tools to run and test them on your own machine.
|
|
4
|
+
|
|
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)
|
|
20
|
+
```
|
|
21
|
+
|
|
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.
|
|
26
|
+
|
|
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.
|
|
29
|
+
|
|
30
|
+
## `@deepexi/datasense-connector`
|
|
31
|
+
|
|
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`.
|
|
54
|
+
|
|
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
|
+
```
|
|
74
|
+
|
|
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`.
|
|
77
|
+
|
|
78
|
+
- Records are sent in batches of at most 1000 records or 4 MiB. Yielding `ctx.checkpoint(state)` sends the
|
|
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.
|
|
86
|
+
- Secrets are handles (`ontos:secret:<field>`) that the gateway replaces on the way out. Plaintext is only
|
|
87
|
+
available for secret fields marked `exposeToScript`.
|
|
88
|
+
- `ctx.http` waits and resends on gateway rate limits and upstream 429. Upstream 502 / 503 / 504 and gateway
|
|
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:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
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) };
|
|
113
|
+
},
|
|
114
|
+
simulate: (params) => ({ externalId: `simulated-${params.shopCode}`, status: 'simulated' }),
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
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
|
|
125
|
+
|
|
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:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
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
|
+
]);
|
|
135
|
+
```
|
|
136
|
+
|
|
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.
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import ts from 'typescript';
|
|
147
|
+
import { loadLocalConnector, runTests } from '@deepexi/datasense-connector/testing';
|
|
148
|
+
|
|
149
|
+
const connector = await loadLocalConnector('.', { typescript: ts });
|
|
150
|
+
const report = await runTests(connector);
|
|
151
|
+
// report: { ok, cases: [{ capability, name, ok, failure }], covered, uncovered }
|
|
152
|
+
```
|
|
153
|
+
|
|
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.
|
|
190
|
+
|
|
191
|
+
The SDK itself has no runtime dependencies. The root entry does not import Node or Bun built-ins, so it can be
|
|
192
|
+
bundled into a connector and run in an isolated VM context; `./testing` needs Node 20+ or Bun.
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepexi/datasense-connector",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"module": "src/index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./testing": "./src/testing/index.ts",
|
|
10
|
+
"./runtime": "./src/runtime/index.ts"
|
|
11
|
+
},
|
|
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"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"postgres": "^3.4.9"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"postgres": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@mu-ontos/shared": "workspace:*",
|
|
32
|
+
"postgres": "^3.4.9",
|
|
33
|
+
"typescript": "^6.0.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/scripts/build.ts
ADDED
|
@@ -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();
|