@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.
- package/README.md +158 -139
- package/package.json +19 -28
- 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/{runtime/guest/limits.d.ts → src/runtime/guest/limits.ts} +12 -12
- 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/cjs/index.cjs +0 -1015
- package/cjs/runtime.cjs +0 -752
- package/cjs/testing.cjs +0 -3591
- package/errors.d.ts +0 -17
- package/errors.js +0 -53
- package/guest.cjs +0 -2659
- package/index.d.ts +0 -13
- package/index.js +0 -7
- package/internal/location.d.ts +0 -6
- package/internal/location.js +0 -8
- package/presentation.d.ts +0 -38
- package/presentation.js +0 -268
- package/runtime/guest/bridge.d.ts +0 -53
- package/runtime/guest/bridge.js +0 -187
- package/runtime/guest/cache.d.ts +0 -3
- package/runtime/guest/cache.js +0 -42
- package/runtime/guest/crypto.d.ts +0 -13
- package/runtime/guest/crypto.js +0 -177
- package/runtime/guest/ctx.d.ts +0 -30
- package/runtime/guest/ctx.js +0 -124
- package/runtime/guest/db.d.ts +0 -6
- package/runtime/guest/db.js +0 -59
- package/runtime/guest/discover.d.ts +0 -29
- package/runtime/guest/discover.js +0 -51
- package/runtime/guest/failure.d.ts +0 -24
- package/runtime/guest/failure.js +0 -63
- package/runtime/guest/http.d.ts +0 -4
- package/runtime/guest/http.js +0 -204
- package/runtime/guest/index.d.ts +0 -12
- package/runtime/guest/index.js +0 -89
- package/runtime/guest/limits.js +0 -12
- package/runtime/guest/loader.d.ts +0 -15
- package/runtime/guest/loader.js +0 -42
- package/runtime/guest/log.d.ts +0 -10
- package/runtime/guest/log.js +0 -36
- package/runtime/guest/map.d.ts +0 -4
- package/runtime/guest/map.js +0 -45
- package/runtime/guest/paginate.d.ts +0 -6
- package/runtime/guest/paginate.js +0 -180
- package/runtime/guest/pull.d.ts +0 -12
- package/runtime/guest/pull.js +0 -150
- package/runtime/guest/push.d.ts +0 -18
- package/runtime/guest/push.js +0 -80
- package/runtime/guest/signal.d.ts +0 -3
- package/runtime/guest/signal.js +0 -12
- package/runtime/guest/time.d.ts +0 -5
- package/runtime/guest/time.js +0 -127
- package/runtime/guest-runtime.d.ts +0 -7
- package/runtime/guest-runtime.js +0 -51
- package/runtime/index.d.ts +0 -18
- package/runtime/index.js +0 -3
- package/runtime/protocol.d.ts +0 -276
- package/runtime/protocol.js +0 -409
- package/runtime/session.d.ts +0 -12
- package/runtime/session.js +0 -191
- package/t.d.ts +0 -18
- package/t.js +0 -26
- package/testing/child.cjs +0 -350
- package/testing/child.d.ts +0 -1
- package/testing/child.js +0 -12
- package/testing/compliance.d.ts +0 -41
- package/testing/compliance.js +0 -291
- package/testing/engine.d.ts +0 -66
- package/testing/engine.js +0 -339
- package/testing/fixtures.d.ts +0 -145
- package/testing/fixtures.js +0 -407
- package/testing/gateway.d.ts +0 -65
- package/testing/gateway.js +0 -402
- package/testing/import-guard.d.ts +0 -57
- package/testing/import-guard.js +0 -187
- package/testing/index.d.ts +0 -33
- package/testing/index.js +0 -10
- package/testing/local-db.d.ts +0 -27
- package/testing/local-db.js +0 -181
- package/testing/pinned-http.d.ts +0 -14
- package/testing/pinned-http.js +0 -125
- package/testing/recorder.d.ts +0 -15
- package/testing/recorder.js +0 -116
- package/testing/redaction.d.ts +0 -28
- package/testing/redaction.js +0 -67
- package/testing/replay.d.ts +0 -50
- package/testing/replay.js +0 -313
- package/testing/schema.d.ts +0 -13
- package/testing/schema.js +0 -65
- package/testing/source-loader.d.ts +0 -19
- package/testing/source-loader.js +0 -238
- package/testing/types.d.ts +0 -79
- package/testing/types.js +0 -18
- package/types.d.ts +0 -302
- package/types.js +0 -13
- package/validate.d.ts +0 -78
- package/validate.js +0 -597
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发布构建:dist 的清单与产物齐全;npm pack 后在 Node 下 ESM import 与 CJS require 都能加载;
|
|
3
|
+
* dist 与源码下的 `loadGuestRuntime()` 都返回能被 `createGuestSession` 启动的 guest 运行时。
|
|
4
|
+
*/
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { createGuestSession, loadGuestRuntime } from '../src/runtime';
|
|
11
|
+
import { descriptor, push, rawConnector } from './fixtures/descriptor';
|
|
12
|
+
|
|
13
|
+
const PACKAGE_DIR = path.resolve(import.meta.dir, '..');
|
|
14
|
+
const WORKSPACE = JSON.parse(await readFile(path.join(PACKAGE_DIR, 'package.json'), 'utf8')) as { name: string; version: string };
|
|
15
|
+
|
|
16
|
+
let base = '';
|
|
17
|
+
let out = '';
|
|
18
|
+
|
|
19
|
+
async function run(command: string[], cwd: string): Promise<string> {
|
|
20
|
+
const child = Bun.spawn(command, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
|
21
|
+
const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
|
|
22
|
+
if (code !== 0) throw new Error(`${command.join(' ')} exited with ${code}\n${stdout}\n${stderr}`);
|
|
23
|
+
return stdout;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function listFiles(dir: string): Promise<string[]> {
|
|
27
|
+
const files: string[] = [];
|
|
28
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
29
|
+
const full = path.join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) files.push(...await listFiles(full));
|
|
31
|
+
else files.push(path.relative(out, full));
|
|
32
|
+
}
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 用 guest 会话启动一段 guest 运行时,跑一个 check 作业,返回结果消息。 */
|
|
37
|
+
function bootCheck(guest: string): Promise<Record<string, unknown>> {
|
|
38
|
+
const desc = descriptor({ check: true, pushes: { default: push({}) } });
|
|
39
|
+
const code = rawConnector(desc, `check: defineCheck(() => {}), pushes: { default: definePush('default', { map() {} }) },`);
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const timer = setTimeout(() => reject(new Error('guest did not answer')), 10_000);
|
|
42
|
+
const session = createGuestSession((json) => {
|
|
43
|
+
const message = JSON.parse(json) as { type: string; id?: number; method?: string };
|
|
44
|
+
if (message.type === 'call') {
|
|
45
|
+
session.handle(JSON.stringify({ type: 'reply', id: message.id, ok: true, value: null }));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolve(message as Record<string, unknown>);
|
|
50
|
+
});
|
|
51
|
+
session.handle(JSON.stringify({
|
|
52
|
+
type: 'execute', guest, code, expect: { name: 'demo', version: '1.0.0' }, job: { kind: 'check', config: {}, secretFields: [] },
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
base = await mkdtemp(path.join(os.tmpdir(), 'sdk-build-'));
|
|
59
|
+
out = path.join(base, 'dist');
|
|
60
|
+
await run([process.execPath, 'scripts/build.ts', '--outdir', out], PACKAGE_DIR);
|
|
61
|
+
}, 120_000);
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
if (base) await rm(base, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('build output', () => {
|
|
68
|
+
test('package.json is publishable and does not touch the workspace manifest', async () => {
|
|
69
|
+
const manifest = JSON.parse(await readFile(path.join(out, 'package.json'), 'utf8')) as Record<string, unknown>;
|
|
70
|
+
expect(manifest.name).toBe('@deepexi/datasense-connector');
|
|
71
|
+
expect(manifest.version).toBe(WORKSPACE.version);
|
|
72
|
+
expect(manifest.dependencies).toEqual({});
|
|
73
|
+
expect(manifest.peerDependencies).toEqual({ postgres: expect.any(String) });
|
|
74
|
+
expect(manifest.peerDependenciesMeta).toEqual({ postgres: { optional: true } });
|
|
75
|
+
const exportsMap = manifest.exports as Record<string, Record<string, string> | string>;
|
|
76
|
+
for (const entry of ['.', './testing', './runtime']) {
|
|
77
|
+
const target = exportsMap[entry] as Record<string, string>;
|
|
78
|
+
expect(Object.keys(target).sort()).toEqual(['import', 'require', 'types']);
|
|
79
|
+
for (const file of Object.values(target)) expect(existsSync(path.join(out, file))).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
const workspaceNow = JSON.parse(await readFile(path.join(PACKAGE_DIR, 'package.json'), 'utf8')) as { exports: Record<string, string> };
|
|
82
|
+
expect(workspaceNow.exports['.']).toBe('./src/index.ts');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('js, d.ts, guest.cjs, child.cjs and README are emitted; postgres and typescript are not bundled', async () => {
|
|
86
|
+
const files = await listFiles(out);
|
|
87
|
+
expect(files).toContain('index.js');
|
|
88
|
+
expect(files).toContain('index.d.ts');
|
|
89
|
+
expect(files).toContain('testing/index.js');
|
|
90
|
+
expect(files).toContain('testing/index.d.ts');
|
|
91
|
+
expect(files).toContain('runtime/index.d.ts');
|
|
92
|
+
expect(files).toContain('guest.cjs');
|
|
93
|
+
expect(files).toContain('testing/child.cjs');
|
|
94
|
+
expect(files).toContain('README.md');
|
|
95
|
+
expect(files.filter((file) => file.includes('.test.'))).toEqual([]);
|
|
96
|
+
const testingCjs = await readFile(path.join(out, 'cjs/testing.cjs'), 'utf8');
|
|
97
|
+
expect(testingCjs).not.toContain('function postgres(');
|
|
98
|
+
expect(testingCjs).not.toContain(PACKAGE_DIR);
|
|
99
|
+
const esm = await readFile(path.join(out, 'testing/index.js'), 'utf8');
|
|
100
|
+
expect(esm).toMatch(/from '\.\/engine\.js'/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('js drops source comments; d.ts keeps doc comments', async () => {
|
|
104
|
+
for (const file of ['index.js', 'validate.js', 'testing/index.js', 'cjs/testing.cjs', 'guest.cjs']) {
|
|
105
|
+
expect(await readFile(path.join(out, file), 'utf8')).not.toContain('/**');
|
|
106
|
+
}
|
|
107
|
+
expect(await readFile(path.join(out, 'validate.d.ts'), 'utf8')).toContain('/**');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('dist loadGuestRuntime returns guest.cjs, which boots in a guest session', async () => {
|
|
111
|
+
const runtime = await import(path.join(out, 'runtime/index.js')) as { loadGuestRuntime(): Promise<string>; GUEST_ENTRY_PATH: string };
|
|
112
|
+
const guest = await runtime.loadGuestRuntime();
|
|
113
|
+
expect(guest).toBe(await readFile(path.join(out, 'guest.cjs'), 'utf8'));
|
|
114
|
+
expect(guest).toContain('bootGuest');
|
|
115
|
+
expect(runtime.GUEST_ENTRY_PATH.endsWith(path.join('dist', 'runtime', 'guest', 'index.js'))).toBe(true);
|
|
116
|
+
expect(existsSync(runtime.GUEST_ENTRY_PATH)).toBe(true);
|
|
117
|
+
const outcome = await bootCheck(guest);
|
|
118
|
+
expect(outcome.type).toBe('result');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('source loadGuestRuntime (Bun) boots in a guest session', async () => {
|
|
122
|
+
const guest = await loadGuestRuntime();
|
|
123
|
+
expect(guest).toContain('bootGuest');
|
|
124
|
+
const outcome = await bootCheck(guest);
|
|
125
|
+
expect(outcome.type).toBe('result');
|
|
126
|
+
expect((outcome.result as { kind: string }).kind).toBe('check');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('Node compatibility', () => {
|
|
131
|
+
const node = Bun.which('node');
|
|
132
|
+
const npm = Bun.which('npm');
|
|
133
|
+
|
|
134
|
+
test.skipIf(!node || !npm)('npm pack + npm install: ESM import and CJS require work under Node', async () => {
|
|
135
|
+
const project = path.join(base, 'consumer');
|
|
136
|
+
await run(['mkdir', '-p', project], base);
|
|
137
|
+
await writeFile(path.join(project, 'package.json'), JSON.stringify({ name: 'consumer', version: '1.0.0', private: true }));
|
|
138
|
+
const packed = (await run([npm as string, 'pack', out, '--pack-destination', base, '--silent'], base)).trim().split('\n').pop() as string;
|
|
139
|
+
await run([npm as string, 'install', '--no-audit', '--no-fund', '--silent', path.join(base, packed)], project);
|
|
140
|
+
const esm = await run([node as string, '--input-type=module', '-e', "import('@deepexi/datasense-connector').then((m) => console.log(m.SDK_VERSION))"], project);
|
|
141
|
+
expect(esm.trim()).toBe(WORKSPACE.version);
|
|
142
|
+
const cjs = await run([node as string, '-e', [
|
|
143
|
+
"const testing = require('@deepexi/datasense-connector/testing');",
|
|
144
|
+
"const sdk = require('@deepexi/datasense-connector');",
|
|
145
|
+
"console.log([typeof testing.createLocalGateway, typeof testing.createVmExecutor, sdk.SDK_VERSION].join(' '));",
|
|
146
|
+
].join('\n')], project);
|
|
147
|
+
expect(cjs.trim()).toBe(`function function ${WORKSPACE.version}`);
|
|
148
|
+
const guest = await run([node as string, '--input-type=module', '-e', [
|
|
149
|
+
"import { loadGuestRuntime } from '@deepexi/datasense-connector/runtime';",
|
|
150
|
+
"const guest = await loadGuestRuntime();",
|
|
151
|
+
"console.log(guest.includes('bootGuest'));",
|
|
152
|
+
].join('\n')], project);
|
|
153
|
+
expect(guest.trim()).toBe('true');
|
|
154
|
+
}, 180_000);
|
|
155
|
+
|
|
156
|
+
if (!node || !npm) test('node is missing: Node compatibility checks are skipped', () => {
|
|
157
|
+
console.warn('node/npm not found on PATH; skipped the npm pack + Node import check');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编写 API 的类型行为(只由 `bun run ts-check` 检查,不被 bun test 运行):每个 `@ts-expect-error` 都必须真的报错,
|
|
3
|
+
* 否则 tsc 报「未使用的 @ts-expect-error」。
|
|
4
|
+
*
|
|
5
|
+
* 两种 D 各测一遍:
|
|
6
|
+
* - `Codegen`:按 shared codegen(`.ontos/types.ts`)的写法手写——顶层 interface、各层 readonly、列全部必填;
|
|
7
|
+
* - `Literal`:`DescriptorTypes<typeof 描述字面量>`。
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
defineCheck, defineOperation, defineStream, defineSuggestColumns,
|
|
11
|
+
type DescriptorTypes, type GeneratedDescriptor,
|
|
12
|
+
} from '../src';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// 按 codegen 形状手写的 D
|
|
16
|
+
|
|
17
|
+
interface Codegen {
|
|
18
|
+
readonly streams: {
|
|
19
|
+
readonly orders: {
|
|
20
|
+
readonly tables: {
|
|
21
|
+
readonly orders: { readonly id: string; readonly amount: string };
|
|
22
|
+
readonly order_lines: { readonly order_id: string; readonly line_no: number };
|
|
23
|
+
};
|
|
24
|
+
readonly params: { readonly pageSize?: number };
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
readonly pushes: Record<never, never>;
|
|
28
|
+
readonly operations: {
|
|
29
|
+
readonly create_followup: {
|
|
30
|
+
readonly idempotency: 'unsupported';
|
|
31
|
+
readonly input: { readonly customerId: string };
|
|
32
|
+
readonly output: { readonly followupId: string; readonly note?: string };
|
|
33
|
+
};
|
|
34
|
+
readonly update_note: {
|
|
35
|
+
readonly idempotency: 'supported';
|
|
36
|
+
readonly input: { readonly id: string };
|
|
37
|
+
readonly output: { readonly ok: boolean };
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
readonly extensibleStreams: Record<never, never>;
|
|
41
|
+
readonly config: { readonly baseUrl: string };
|
|
42
|
+
readonly secrets: { readonly token: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// codegen 包已绑定版本的写法能编译
|
|
46
|
+
export function boundStream<N extends keyof Codegen['streams'] & string>(
|
|
47
|
+
name: N,
|
|
48
|
+
fn: Parameters<typeof defineStream<Codegen, N>>[1],
|
|
49
|
+
): ReturnType<typeof defineStream<Codegen, N>> {
|
|
50
|
+
return defineStream<Codegen, N>(name, fn);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
boundStream('orders', async function* (ctx, input) {
|
|
54
|
+
const base: string = ctx.config.baseUrl;
|
|
55
|
+
const size: number | undefined = input.streamConfig.pageSize;
|
|
56
|
+
yield ctx.record('orders', { id: `${base}${size ?? 0}`, amount: '1.00' });
|
|
57
|
+
yield ctx.record('order_lines', { order_id: 'o1', line_no: 1 });
|
|
58
|
+
// @ts-expect-error 表名 order 不存在
|
|
59
|
+
yield ctx.record('order', { id: 'x', amount: '1' });
|
|
60
|
+
// @ts-expect-error id 声明为 string
|
|
61
|
+
yield ctx.record('orders', { id: 1, amount: '1' });
|
|
62
|
+
// @ts-expect-error 配置项是 baseUrl,不是 baseURL
|
|
63
|
+
void ctx.config.baseURL;
|
|
64
|
+
// @ts-expect-error baseUrl 不是 secret
|
|
65
|
+
void ctx.secrets.baseUrl;
|
|
66
|
+
const token: string = ctx.secrets.token;
|
|
67
|
+
void token;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// @ts-expect-error 数据流 customers 不在描述里
|
|
71
|
+
boundStream('customers', async function* () {});
|
|
72
|
+
|
|
73
|
+
defineCheck<Codegen>(async (ctx) => {
|
|
74
|
+
const base: string = ctx.config.baseUrl;
|
|
75
|
+
void base;
|
|
76
|
+
// @ts-expect-error 配置项是 baseUrl,不是 baseURL
|
|
77
|
+
void ctx.config.baseURL;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// unsupported 必须给 lookup
|
|
81
|
+
defineOperation<Codegen, 'create_followup'>('create_followup', {
|
|
82
|
+
async run(_ctx, params) { return { followupId: params.customerId }; },
|
|
83
|
+
async lookup() { return { status: 'not_applied' as const }; },
|
|
84
|
+
});
|
|
85
|
+
// @ts-expect-error unsupported 没给 lookup
|
|
86
|
+
defineOperation<Codegen, 'create_followup'>('create_followup', {
|
|
87
|
+
async run(_ctx, params) { return { followupId: params.customerId }; },
|
|
88
|
+
});
|
|
89
|
+
// supported 时 lookup 可选
|
|
90
|
+
defineOperation<Codegen, 'update_note'>('update_note', {
|
|
91
|
+
async run() { return { ok: true }; },
|
|
92
|
+
});
|
|
93
|
+
defineOperation<Codegen, 'update_note'>('update_note', {
|
|
94
|
+
// @ts-expect-error 输出缺必填字段 ok
|
|
95
|
+
async run() { return {}; },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// @ts-expect-error 没有可扩展表时没有可用的数据流名
|
|
99
|
+
defineSuggestColumns<Codegen, 'orders'>('orders', async () => []);
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// 从描述字面量推出的 D
|
|
103
|
+
|
|
104
|
+
export const literal = {
|
|
105
|
+
config: {
|
|
106
|
+
baseUrl: { type: 'url', title: 'Base URL', description: 'x', placeholder: null, optional: false },
|
|
107
|
+
region: { type: 'enum', values: ['cn', 'us'], title: 'Region', description: 'x', placeholder: null, optional: true },
|
|
108
|
+
token: { type: 'secret', title: 'Token', description: 'x', placeholder: null, optional: false },
|
|
109
|
+
},
|
|
110
|
+
streams: {
|
|
111
|
+
orders: {
|
|
112
|
+
params: {},
|
|
113
|
+
tables: {
|
|
114
|
+
orders: { primaryKey: ['id'], extensible: false, columns: { id: { type: 'string' }, amount: { type: 'decimal', precision: 12, scale: 2 } } },
|
|
115
|
+
order_lines: { primaryKey: ['order_id', 'line_no'], extensible: false, columns: { order_id: { type: 'string' }, line_no: { type: 'integer' } } },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
customers: {
|
|
119
|
+
params: {},
|
|
120
|
+
tables: { customers: { primaryKey: ['id'], extensible: true, columns: { id: { type: 'string' }, name: { type: 'string' } } } },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
pushes: {},
|
|
124
|
+
operations: {
|
|
125
|
+
create_followup: {
|
|
126
|
+
idempotency: 'unsupported',
|
|
127
|
+
input: { customerId: { type: 'string', optional: false } },
|
|
128
|
+
output: { followupId: { type: 'string', optional: false }, note: { type: 'string', optional: true } },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
} as const;
|
|
132
|
+
|
|
133
|
+
type Literal = DescriptorTypes<typeof literal>;
|
|
134
|
+
|
|
135
|
+
// 推出的类型满足 GeneratedDescriptor
|
|
136
|
+
const satisfied: GeneratedDescriptor = null as unknown as Literal;
|
|
137
|
+
void satisfied;
|
|
138
|
+
|
|
139
|
+
defineStream<Literal, 'orders'>('orders', async function* (ctx) {
|
|
140
|
+
const base: string = ctx.config.baseUrl;
|
|
141
|
+
const region: 'cn' | 'us' | undefined = ctx.config.region;
|
|
142
|
+
void region;
|
|
143
|
+
// 非主键列可缺省
|
|
144
|
+
yield ctx.record('orders', { id: base });
|
|
145
|
+
// @ts-expect-error 表名 order 不存在
|
|
146
|
+
yield ctx.record('order', { id: 'x' });
|
|
147
|
+
// @ts-expect-error id 声明为 string
|
|
148
|
+
yield ctx.record('orders', { id: 1 });
|
|
149
|
+
// @ts-expect-error 主键列必填
|
|
150
|
+
yield ctx.record('order_lines', { order_id: 'o1' });
|
|
151
|
+
// @ts-expect-error 不可扩展表不接受未声明字段
|
|
152
|
+
yield ctx.record('orders', { id: 'x', foo: 1 });
|
|
153
|
+
// @ts-expect-error 配置项是 baseUrl,不是 baseURL
|
|
154
|
+
void ctx.config.baseURL;
|
|
155
|
+
// @ts-expect-error baseUrl 不是 secret
|
|
156
|
+
void ctx.secrets.baseUrl;
|
|
157
|
+
const token: string = ctx.secrets.token;
|
|
158
|
+
void token;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
defineStream<Literal, 'customers'>('customers', async function* (ctx) {
|
|
162
|
+
// 可扩展表接受额外字段
|
|
163
|
+
yield ctx.record('customers', { id: 'c1', vip_level: 'gold' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// @ts-expect-error unsupported 没给 lookup
|
|
167
|
+
defineOperation<Literal, 'create_followup'>('create_followup', {
|
|
168
|
+
async run(_ctx, params) { return { followupId: params.customerId }; },
|
|
169
|
+
});
|
|
170
|
+
defineOperation<Literal, 'create_followup'>('create_followup', {
|
|
171
|
+
// @ts-expect-error 输出缺必填字段 followupId
|
|
172
|
+
async run() { return { note: 'x' }; },
|
|
173
|
+
async lookup() { return { status: 'not_applied' as const }; },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 有可扩展表的数据流可以写建议列,table 只能是它的可扩展表
|
|
177
|
+
defineSuggestColumns<Literal, 'customers'>('customers', async (_ctx, input) => {
|
|
178
|
+
const table: 'customers' = input.table;
|
|
179
|
+
return [{ name: 'vip_level', type: 'string', title: table }];
|
|
180
|
+
});
|
|
181
|
+
// @ts-expect-error orders 没有可扩展表
|
|
182
|
+
defineSuggestColumns<Literal, 'orders'>('orders', async () => []);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两个验收连接器(`test/fixtures/connectors/{http-pull,pg-pull}`,按描述布局)的录制输入:
|
|
3
|
+
* 录制脚本 `record-acceptance.ts` 与测试 `test/testing/acceptance-fixtures.test.ts` 共用。
|
|
4
|
+
*
|
|
5
|
+
* 录下的用例放在连接器目录的 `streams/<数据流>/tests/<用例名>/`(`readFixtureDir(<连接器目录>)` 读)。
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import postgres from 'postgres';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import {
|
|
11
|
+
createLocalGateway, createRecorder, createVmExecutor, loadLocalConnector, serializeFixture,
|
|
12
|
+
type ConnectorFixtureFile, type FixtureCase, type LocalGatewayOptions, type LocalRunResult,
|
|
13
|
+
} from '../../src/testing';
|
|
14
|
+
|
|
15
|
+
export const CONNECTORS_DIR = path.resolve(import.meta.dir, 'connectors');
|
|
16
|
+
export const HTTP_PULL_DIR = path.join(CONNECTORS_DIR, 'http-pull');
|
|
17
|
+
export const PG_PULL_DIR = path.join(CONNECTORS_DIR, 'pg-pull');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// http-pull:假上游两页
|
|
21
|
+
|
|
22
|
+
export const HTTP_SECRETS = { token: 'tok-secret-1' };
|
|
23
|
+
export const HTTP_CONFIG = { baseUrl: 'https://api.example.com/v1' };
|
|
24
|
+
|
|
25
|
+
const PAGES: Record<string, { items: Array<{ id: string; name: string; updatedAt: string }>; nextCursor: string | null }> = {
|
|
26
|
+
first: {
|
|
27
|
+
items: [
|
|
28
|
+
{ id: 'a', name: 'Alpha', updatedAt: '2026-10-01T00:00:00.000Z' },
|
|
29
|
+
{ id: 'b', name: 'Beta', updatedAt: '2026-10-02T00:00:00.000Z' },
|
|
30
|
+
],
|
|
31
|
+
nextCursor: 'p2',
|
|
32
|
+
},
|
|
33
|
+
p2: { items: [{ id: 'c', name: 'Gamma', updatedAt: '2026-10-03T00:00:00.000Z' }], nextCursor: null },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** 假上游:鉴权头不是 `Bearer <token>` 时回 401;没有 cursor 回第一页,`cursor=p2` 回第二页。 */
|
|
37
|
+
export function twoPageFetch(): typeof fetch {
|
|
38
|
+
const impl = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
39
|
+
const url = new URL(typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url);
|
|
40
|
+
if (new Headers(init?.headers).get('authorization') !== `Bearer ${HTTP_SECRETS.token}`) return new Response('unauthorized', { status: 401 });
|
|
41
|
+
return Response.json(PAGES[url.searchParams.get('cursor') ?? 'first']);
|
|
42
|
+
};
|
|
43
|
+
return impl as typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const HTTP_CASE_NAME = 'basic';
|
|
47
|
+
export const HTTP_CASE: FixtureCase = {
|
|
48
|
+
kind: 'pull',
|
|
49
|
+
stream: 'items',
|
|
50
|
+
mode: 'full',
|
|
51
|
+
config: HTTP_CONFIG,
|
|
52
|
+
secrets: HTTP_SECRETS,
|
|
53
|
+
expect: { records: { items: 3 }, checkpoints: 2 },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// pg-pull:本机 :15432 的 Postgres 里建一个模式
|
|
58
|
+
|
|
59
|
+
export const PG_TARGET = { host: 'localhost', port: 15432, database: 'postgres', user: 'admin', password: 'password' };
|
|
60
|
+
export const PG_SCHEMA = 'sdk_fixture_pg_pull';
|
|
61
|
+
|
|
62
|
+
export const PG_CASE_NAME = 'customers-full';
|
|
63
|
+
export const PG_CASE: FixtureCase = {
|
|
64
|
+
kind: 'pull',
|
|
65
|
+
stream: 'customers',
|
|
66
|
+
mode: 'full',
|
|
67
|
+
streamConfig: { pageSize: 2 },
|
|
68
|
+
config: { schema: PG_SCHEMA },
|
|
69
|
+
expect: { records: { customers: 3 }, checkpoints: 2 },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** 本机 :15432 的 Postgres 能否连上:能连上返回 null,否则返回原因。测试文件在顶层 await 一次,配合 skipIf 使用。 */
|
|
73
|
+
export async function localPgUnavailable(): Promise<string | null> {
|
|
74
|
+
const sql = postgres({ ...PG_TARGET, username: PG_TARGET.user, max: 1, connect_timeout: 5, onnotice: () => {} });
|
|
75
|
+
try {
|
|
76
|
+
await sql`select 1`;
|
|
77
|
+
return null;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return error instanceof Error ? error.message : String(error);
|
|
80
|
+
} finally {
|
|
81
|
+
await sql.end({ timeout: 5 }).catch(() => undefined);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 重建录制用的模式与数据;连不上时返回 false,连上之后的 SQL 错误照常抛出。 */
|
|
86
|
+
export async function preparePgSchema(): Promise<boolean> {
|
|
87
|
+
const sql = postgres({ ...PG_TARGET, username: PG_TARGET.user, max: 1, connect_timeout: 5, onnotice: () => {} });
|
|
88
|
+
try {
|
|
89
|
+
try {
|
|
90
|
+
await sql`select 1`;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
await sql.unsafe(`drop schema if exists ${PG_SCHEMA} cascade`);
|
|
95
|
+
await sql.unsafe(`create schema ${PG_SCHEMA}`);
|
|
96
|
+
await sql.unsafe(`create table ${PG_SCHEMA}.customers (
|
|
97
|
+
id text primary key, name text not null, balance numeric(12,2), active boolean, joined date, updated_at timestamptz, profile jsonb)`);
|
|
98
|
+
await sql.unsafe(`create table ${PG_SCHEMA}.orders (id text primary key, customer_id text, amount integer, updated_at timestamptz)`);
|
|
99
|
+
await sql.unsafe(`insert into ${PG_SCHEMA}.customers values
|
|
100
|
+
('c1', 'Ada', 120.50, true, '2026-01-02', '2026-10-01T08:00:00Z', '{"tier":"gold"}'),
|
|
101
|
+
('c2', 'Bob', 0, false, '2026-02-03', '2026-10-02T08:00:00Z', null),
|
|
102
|
+
('c3', 'Cyd', null, null, null, null, '{"tags":["x"]}')`);
|
|
103
|
+
return true;
|
|
104
|
+
} finally {
|
|
105
|
+
await sql.end({ timeout: 5 }).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function dropPgSchema(): Promise<void> {
|
|
110
|
+
const sql = postgres({ ...PG_TARGET, username: PG_TARGET.user, max: 1, connect_timeout: 5, onnotice: () => {} });
|
|
111
|
+
try {
|
|
112
|
+
await sql.unsafe(`drop schema if exists ${PG_SCHEMA} cascade`);
|
|
113
|
+
} catch {
|
|
114
|
+
// 连不上时什么也不做
|
|
115
|
+
} finally {
|
|
116
|
+
await sql.end({ timeout: 5 }).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// 录制
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 按 CLI `read --record` 的做法录一个拉取用例:源码目录经 `loadLocalConnector` 在 vm 子进程里执行(作业表取描述里该数据流的表),
|
|
125
|
+
* 本地模拟网关的往来交给录制器,最后 `serializeFixture`。
|
|
126
|
+
*/
|
|
127
|
+
export async function recordPullCase(
|
|
128
|
+
sourceDir: string,
|
|
129
|
+
name: string,
|
|
130
|
+
fixtureCase: Extract<FixtureCase, { kind: 'pull' }>,
|
|
131
|
+
gateway: Omit<LocalGatewayOptions, 'onExchange' | 'secrets'>,
|
|
132
|
+
): Promise<{ files: ConnectorFixtureFile[]; result: LocalRunResult }> {
|
|
133
|
+
const secrets = fixtureCase.secrets ?? {};
|
|
134
|
+
const recorder = createRecorder({ secrets });
|
|
135
|
+
const host = createLocalGateway({ ...gateway, secrets, onExchange: (exchange) => recorder.onExchange(exchange) });
|
|
136
|
+
const connector = await loadLocalConnector(sourceDir, { typescript: ts });
|
|
137
|
+
try {
|
|
138
|
+
const tables = Object.keys(connector.descriptor.streams[fixtureCase.stream]?.tables ?? {});
|
|
139
|
+
const result = await createVmExecutor(connector).run({
|
|
140
|
+
kind: 'pull', config: fixtureCase.config, secretFields: Object.keys(secrets), stream: fixtureCase.stream, mode: fixtureCase.mode,
|
|
141
|
+
state: fixtureCase.state ?? null, streamConfig: fixtureCase.streamConfig ?? {}, tables,
|
|
142
|
+
}, host, fixtureCase.limit !== undefined ? { limit: fixtureCase.limit } : {});
|
|
143
|
+
return { files: serializeFixture(name, { case: fixtureCase, exchanges: recorder.exchanges() }), result };
|
|
144
|
+
} finally {
|
|
145
|
+
await host.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function recordHttpPull(): Promise<{ files: ConnectorFixtureFile[]; result: LocalRunResult }> {
|
|
150
|
+
return recordPullCase(HTTP_PULL_DIR, HTTP_CASE_NAME, HTTP_CASE as Extract<FixtureCase, { kind: 'pull' }>, { hosts: ['api.example.com'], fetch: twoPageFetch() });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 先 `preparePgSchema()`。 */
|
|
154
|
+
export function recordPgPull(): Promise<{ files: ConnectorFixtureFile[]; result: LocalRunResult }> {
|
|
155
|
+
return recordPullCase(PG_PULL_DIR, PG_CASE_NAME, PG_CASE as Extract<FixtureCase, { kind: 'pull' }>, { hosts: [], db: PG_TARGET });
|
|
156
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "http_pull",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"title": "HTTP pull",
|
|
5
|
+
"description": "Acceptance fixture: pull from a paginated HTTP API",
|
|
6
|
+
"icon": null,
|
|
7
|
+
"config": {
|
|
8
|
+
"baseUrl": {
|
|
9
|
+
"type": "url",
|
|
10
|
+
"title": "Base URL",
|
|
11
|
+
"description": "Base URL",
|
|
12
|
+
"placeholder": null,
|
|
13
|
+
"optional": false
|
|
14
|
+
},
|
|
15
|
+
"token": {
|
|
16
|
+
"type": "secret",
|
|
17
|
+
"title": "Token",
|
|
18
|
+
"description": "Token",
|
|
19
|
+
"placeholder": null,
|
|
20
|
+
"optional": false
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"hosts": [],
|
|
24
|
+
"db": null,
|
|
25
|
+
"rateLimit": null,
|
|
26
|
+
"check": false,
|
|
27
|
+
"streams": {
|
|
28
|
+
"items": {
|
|
29
|
+
"title": "Items",
|
|
30
|
+
"description": "Items",
|
|
31
|
+
"syncModes": [
|
|
32
|
+
"full",
|
|
33
|
+
"incremental"
|
|
34
|
+
],
|
|
35
|
+
"snapshot": "complete",
|
|
36
|
+
"params": {},
|
|
37
|
+
"tables": {
|
|
38
|
+
"items": {
|
|
39
|
+
"title": "Items",
|
|
40
|
+
"description": "Items",
|
|
41
|
+
"primaryKey": [
|
|
42
|
+
"id"
|
|
43
|
+
],
|
|
44
|
+
"extensible": false,
|
|
45
|
+
"columns": {
|
|
46
|
+
"id": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"title": "ID",
|
|
49
|
+
"description": null
|
|
50
|
+
},
|
|
51
|
+
"name": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"title": "Name",
|
|
54
|
+
"description": null
|
|
55
|
+
},
|
|
56
|
+
"updated_at": {
|
|
57
|
+
"type": "instant",
|
|
58
|
+
"title": "Updated at",
|
|
59
|
+
"description": null
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"retention": null,
|
|
63
|
+
"purgeColumns": null,
|
|
64
|
+
"indexes": []
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"suggestColumns": false
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"pushes": {},
|
|
71
|
+
"operations": {}
|
|
72
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-empty-object-type -- 原样保留 shared codegen 的产物(空能力组生成 `{}`) */
|
|
2
|
+
// 由 ontos admin connector codegen 根据连接器描述生成。不要手改。
|
|
3
|
+
import {
|
|
4
|
+
defineCheck as defineCheckFor,
|
|
5
|
+
defineOperation as defineOperationFor,
|
|
6
|
+
definePush as definePushFor,
|
|
7
|
+
defineStream as defineStreamFor,
|
|
8
|
+
defineSuggestColumns as defineSuggestColumnsFor,
|
|
9
|
+
} from '@deepexi/datasense-connector';
|
|
10
|
+
|
|
11
|
+
/** 这份描述绑定到编写 API 上的类型参数。 */
|
|
12
|
+
export interface GeneratedDescriptor {
|
|
13
|
+
readonly streams: {
|
|
14
|
+
readonly items: { readonly tables: { readonly items: { readonly id: string; readonly name: string; readonly updated_at: string; }; }; readonly params: { }; };
|
|
15
|
+
};
|
|
16
|
+
readonly pushes: {
|
|
17
|
+
|
|
18
|
+
};
|
|
19
|
+
readonly operations: {
|
|
20
|
+
|
|
21
|
+
};
|
|
22
|
+
readonly extensibleStreams: {
|
|
23
|
+
|
|
24
|
+
};
|
|
25
|
+
readonly config: { readonly baseUrl: string; };
|
|
26
|
+
readonly secrets: { readonly token: string; };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defineStream<N extends keyof GeneratedDescriptor['streams'] & string>(
|
|
30
|
+
name: N,
|
|
31
|
+
fn: Parameters<typeof defineStreamFor<GeneratedDescriptor, N>>[1],
|
|
32
|
+
): ReturnType<typeof defineStreamFor<GeneratedDescriptor, N>> {
|
|
33
|
+
return defineStreamFor<GeneratedDescriptor, N>(name, fn);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function definePush<N extends keyof GeneratedDescriptor['pushes'] & string>(
|
|
37
|
+
name: N,
|
|
38
|
+
fn: Parameters<typeof definePushFor<GeneratedDescriptor, N>>[1],
|
|
39
|
+
): ReturnType<typeof definePushFor<GeneratedDescriptor, N>> {
|
|
40
|
+
return definePushFor<GeneratedDescriptor, N>(name, fn);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function defineOperation<N extends keyof GeneratedDescriptor['operations'] & string>(
|
|
44
|
+
name: N,
|
|
45
|
+
fn: Parameters<typeof defineOperationFor<GeneratedDescriptor, N>>[1],
|
|
46
|
+
): ReturnType<typeof defineOperationFor<GeneratedDescriptor, N>> {
|
|
47
|
+
return defineOperationFor<GeneratedDescriptor, N>(name, fn);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function defineCheck(
|
|
51
|
+
fn: Parameters<typeof defineCheckFor<GeneratedDescriptor>>[0],
|
|
52
|
+
): ReturnType<typeof defineCheckFor<GeneratedDescriptor>> {
|
|
53
|
+
return defineCheckFor<GeneratedDescriptor>(fn);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function defineSuggestColumns<N extends keyof GeneratedDescriptor['extensibleStreams'] & string>(
|
|
57
|
+
name: N,
|
|
58
|
+
fn: Parameters<typeof defineSuggestColumnsFor<GeneratedDescriptor, N>>[1],
|
|
59
|
+
): ReturnType<typeof defineSuggestColumnsFor<GeneratedDescriptor, N>> {
|
|
60
|
+
return defineSuggestColumnsFor<GeneratedDescriptor, N>(name, fn);
|
|
61
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 验收夹具:从分页的 HTTP API 拉数(`http_pull@1.0.0`,只作 SDK 测试夹具)。
|
|
3
|
+
*
|
|
4
|
+
* 上游接口:`GET <baseUrl>/items[?updatedSince=<ISO>]` 返回 `{ items, nextCursor }`,条目按 `updatedAt` 升序;
|
|
5
|
+
* 有下一页时 `GET <baseUrl>/items?cursor=<nextCursor>`(游标自带首页的过滤条件)。鉴权 `Authorization: Bearer <token>`,
|
|
6
|
+
* 脚本里的 `ctx.secrets.token` 只是凭证句柄,出站网关替换成明文。
|
|
7
|
+
*
|
|
8
|
+
* 断点 `{ updatedSince, cursor? }`:每页之后提交一次,`updatedSince` 是至今见过的最大 `updatedAt`,`cursor` 是下一页;
|
|
9
|
+
* 最后一页之后只剩 `{ updatedSince }`。
|
|
10
|
+
*/
|
|
11
|
+
import { defineStream } from '../../.ontos/types';
|
|
12
|
+
import { mapItem, type RawItem } from './map';
|
|
13
|
+
|
|
14
|
+
interface ItemsPage { items: RawItem[]; nextCursor?: string | null }
|
|
15
|
+
interface ItemsState { updatedSince: string | null; cursor?: string }
|
|
16
|
+
|
|
17
|
+
export default defineStream('items', async function* (ctx, input) {
|
|
18
|
+
const state = input.state as ItemsState | undefined;
|
|
19
|
+
const url = `${ctx.config.baseUrl.replace(/\/+$/, '')}/items`;
|
|
20
|
+
let high = state?.updatedSince ?? null;
|
|
21
|
+
let cursor = state?.cursor;
|
|
22
|
+
// 续跑沿用断点的游标;新的一次增量从 updatedSince 起,全量从头读。
|
|
23
|
+
const since = cursor === undefined && input.mode === 'incremental' ? high : null;
|
|
24
|
+
for (let page = 1; ; page += 1) {
|
|
25
|
+
const query: Record<string, string> = cursor !== undefined ? { cursor } : since !== null ? { updatedSince: since } : {};
|
|
26
|
+
const response = await ctx.http.get<ItemsPage>(url, { query, headers: { authorization: `Bearer ${ctx.secrets.token}` } });
|
|
27
|
+
for (const raw of response.data.items) {
|
|
28
|
+
const row = mapItem(raw);
|
|
29
|
+
if (high === null || row.updated_at > high) high = row.updated_at;
|
|
30
|
+
yield ctx.record('items', row);
|
|
31
|
+
}
|
|
32
|
+
cursor = response.data.nextCursor ?? undefined;
|
|
33
|
+
ctx.log.info(`page ${page}: ${response.data.items.length} item(s), next cursor ${cursor ?? 'none'}`);
|
|
34
|
+
yield ctx.checkpoint(cursor !== undefined ? { updatedSince: high, cursor } : { updatedSince: high });
|
|
35
|
+
if (cursor === undefined) return;
|
|
36
|
+
}
|
|
37
|
+
});
|