@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
package/cjs/testing.cjs
DELETED
|
@@ -1,3591 +0,0 @@
|
|
|
1
|
-
var __create = Object.create;
|
|
2
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
3
|
-
var __defProp = Object.defineProperty;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
-
function __accessProp(key) {
|
|
8
|
-
return this[key];
|
|
9
|
-
}
|
|
10
|
-
var __toESMCache_node;
|
|
11
|
-
var __toESMCache_esm;
|
|
12
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
13
|
-
var canCache = mod != null && typeof mod === "object";
|
|
14
|
-
if (canCache) {
|
|
15
|
-
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
16
|
-
var cached = cache.get(mod);
|
|
17
|
-
if (cached)
|
|
18
|
-
return cached;
|
|
19
|
-
}
|
|
20
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
21
|
-
const to = isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
22
|
-
if (mod && typeof mod === "object" || typeof mod === "function") {
|
|
23
|
-
for (let key of __getOwnPropNames(mod))
|
|
24
|
-
if (!__hasOwnProp.call(to, key))
|
|
25
|
-
__defProp(to, key, {
|
|
26
|
-
get: __accessProp.bind(mod, key),
|
|
27
|
-
enumerable: true
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
if (canCache)
|
|
31
|
-
cache.set(mod, to);
|
|
32
|
-
return to;
|
|
33
|
-
};
|
|
34
|
-
var __toCommonJS = (from) => {
|
|
35
|
-
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
36
|
-
if (entry)
|
|
37
|
-
return entry;
|
|
38
|
-
entry = __defProp({}, "__esModule", { value: true });
|
|
39
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
40
|
-
for (var key of __getOwnPropNames(from))
|
|
41
|
-
if (!__hasOwnProp.call(entry, key))
|
|
42
|
-
__defProp(entry, key, {
|
|
43
|
-
get: __accessProp.bind(from, key),
|
|
44
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
__moduleCache.set(from, entry);
|
|
48
|
-
return entry;
|
|
49
|
-
};
|
|
50
|
-
var __moduleCache;
|
|
51
|
-
var __returnValue = (v) => v;
|
|
52
|
-
function __exportSetter(name, newValue) {
|
|
53
|
-
this[name] = __returnValue.bind(null, newValue);
|
|
54
|
-
}
|
|
55
|
-
var __export = (target, all) => {
|
|
56
|
-
for (var name in all)
|
|
57
|
-
__defProp(target, name, {
|
|
58
|
-
get: all[name],
|
|
59
|
-
enumerable: true,
|
|
60
|
-
configurable: true,
|
|
61
|
-
set: __exportSetter.bind(all, name)
|
|
62
|
-
});
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// src/testing/index.ts
|
|
66
|
-
var exports_testing = {};
|
|
67
|
-
__export(exports_testing, {
|
|
68
|
-
COMPLIANCE_CODES: () => COMPLIANCE_CODES,
|
|
69
|
-
CREDENTIAL_HEADER_NAMES: () => CREDENTIAL_HEADER_NAMES,
|
|
70
|
-
FixtureFormatError: () => FixtureFormatError,
|
|
71
|
-
FixtureReplayError: () => FixtureReplayError,
|
|
72
|
-
LocalRunError: () => LocalRunError,
|
|
73
|
-
SourceLoadError: () => SourceLoadError,
|
|
74
|
-
checkCompliance: () => checkCompliance,
|
|
75
|
-
createInProcessExecutor: () => createInProcessExecutor,
|
|
76
|
-
createLocalGateway: () => createLocalGateway,
|
|
77
|
-
createRecorder: () => createRecorder,
|
|
78
|
-
createReplayHost: () => createReplayHost,
|
|
79
|
-
createVmExecutor: () => createVmExecutor,
|
|
80
|
-
inferRecordSchema: () => inferRecordSchema,
|
|
81
|
-
inspectDefinition: () => inspectDefinition,
|
|
82
|
-
loadConnectorSource: () => loadConnectorSource,
|
|
83
|
-
parseFixtures: () => parseFixtures,
|
|
84
|
-
readFixtureDir: () => readFixtureDir,
|
|
85
|
-
redactHeaders: () => redactHeaders,
|
|
86
|
-
redactQuery: () => redactQuery,
|
|
87
|
-
redactSecretValues: () => redactSecretValues,
|
|
88
|
-
redactUrl: () => redactUrl,
|
|
89
|
-
runCheck: () => runCheck,
|
|
90
|
-
runDiscover: () => runDiscover,
|
|
91
|
-
runFixture: () => runFixture,
|
|
92
|
-
runFixtures: () => runFixtures,
|
|
93
|
-
runPush: () => runPush,
|
|
94
|
-
runStream: () => runStream,
|
|
95
|
-
secretRedactionTexts: () => secretRedactionTexts,
|
|
96
|
-
serializeFixture: () => serializeFixture
|
|
97
|
-
});
|
|
98
|
-
module.exports = __toCommonJS(exports_testing);
|
|
99
|
-
|
|
100
|
-
// src/testing/types.ts
|
|
101
|
-
class LocalRunError extends Error {
|
|
102
|
-
code;
|
|
103
|
-
constructor(code, message) {
|
|
104
|
-
super(message);
|
|
105
|
-
this.name = "LocalRunError";
|
|
106
|
-
this.code = code;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
class SourceLoadError extends Error {
|
|
111
|
-
code;
|
|
112
|
-
file;
|
|
113
|
-
constructor(code, message, file = null) {
|
|
114
|
-
super(message);
|
|
115
|
-
this.name = "SourceLoadError";
|
|
116
|
-
this.code = code;
|
|
117
|
-
this.file = file;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
// src/testing/gateway.ts
|
|
121
|
-
var import_node_crypto2 = require("node:crypto");
|
|
122
|
-
var import_promises = require("node:dns/promises");
|
|
123
|
-
|
|
124
|
-
// src/testing/local-db.ts
|
|
125
|
-
var import_node_crypto = require("node:crypto");
|
|
126
|
-
|
|
127
|
-
// src/testing/redaction.ts
|
|
128
|
-
var CREDENTIAL_HEADER_NAMES = Object.freeze([
|
|
129
|
-
"authorization",
|
|
130
|
-
"proxy-authorization",
|
|
131
|
-
"cookie",
|
|
132
|
-
"set-cookie",
|
|
133
|
-
"x-api-key",
|
|
134
|
-
"x-auth-token"
|
|
135
|
-
]);
|
|
136
|
-
var REDACT_MIN_LENGTH = 4;
|
|
137
|
-
var REDACTED = "***";
|
|
138
|
-
var CREDENTIAL_HEADER_SET = new Set(CREDENTIAL_HEADER_NAMES);
|
|
139
|
-
var HANDLE_PATTERN = /ontos(?::|%3[Aa])secret(?::|%3[Aa])/;
|
|
140
|
-
function containsSecretHandle(text) {
|
|
141
|
-
return HANDLE_PATTERN.test(text);
|
|
142
|
-
}
|
|
143
|
-
function redactSecretValues(text, secrets) {
|
|
144
|
-
const candidates = [...new Set(secrets.filter((secret) => typeof secret === "string" && secret.length >= REDACT_MIN_LENGTH))].sort((a, b) => b.length - a.length);
|
|
145
|
-
let result = text;
|
|
146
|
-
for (const secret of candidates) {
|
|
147
|
-
if (result.includes(secret))
|
|
148
|
-
result = result.split(secret).join(REDACTED);
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
function secretRedactionTexts(secrets) {
|
|
153
|
-
const result = new Set;
|
|
154
|
-
for (const secret of secrets) {
|
|
155
|
-
if (typeof secret !== "string")
|
|
156
|
-
continue;
|
|
157
|
-
result.add(secret);
|
|
158
|
-
result.add(encodeURIComponent(secret));
|
|
159
|
-
result.add(new URLSearchParams([["", secret]]).toString().slice(1));
|
|
160
|
-
}
|
|
161
|
-
return [...result];
|
|
162
|
-
}
|
|
163
|
-
function redactHeaders(headers, secrets) {
|
|
164
|
-
const result = {};
|
|
165
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
166
|
-
result[name] = CREDENTIAL_HEADER_SET.has(name.toLowerCase()) ? REDACTED : redactSecretValues(String(value), secrets);
|
|
167
|
-
}
|
|
168
|
-
return result;
|
|
169
|
-
}
|
|
170
|
-
function redactQuery(query, secrets) {
|
|
171
|
-
const one = (value) => containsSecretHandle(value) ? REDACTED : redactSecretValues(value, secrets);
|
|
172
|
-
const result = {};
|
|
173
|
-
for (const [name, value] of Object.entries(query))
|
|
174
|
-
result[name] = Array.isArray(value) ? value.map(one) : one(value);
|
|
175
|
-
return result;
|
|
176
|
-
}
|
|
177
|
-
function redactUrl(url, secrets) {
|
|
178
|
-
let text = url;
|
|
179
|
-
try {
|
|
180
|
-
const parsed = new URL(url);
|
|
181
|
-
const entries = [...parsed.searchParams.entries()];
|
|
182
|
-
if (entries.some(([, value]) => containsSecretHandle(value))) {
|
|
183
|
-
const masked = new URLSearchParams;
|
|
184
|
-
for (const [name, value] of entries)
|
|
185
|
-
masked.append(name, containsSecretHandle(value) ? REDACTED : value);
|
|
186
|
-
parsed.search = masked.toString();
|
|
187
|
-
text = parsed.toString();
|
|
188
|
-
}
|
|
189
|
-
} catch {}
|
|
190
|
-
return redactSecretValues(text, secrets);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// src/testing/local-db.ts
|
|
194
|
-
var BATCH_ROWS = 1000;
|
|
195
|
-
var STATEMENT_TIMEOUT_MS = 300000;
|
|
196
|
-
var APPLICATION_NAME = "datasense-connector-local";
|
|
197
|
-
var RAW_TEXT_OIDS = [1082, 1083, 1114, 1266];
|
|
198
|
-
function isRecord(value) {
|
|
199
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
200
|
-
}
|
|
201
|
-
function factoryOf(driver) {
|
|
202
|
-
if (typeof driver === "function")
|
|
203
|
-
return driver;
|
|
204
|
-
if (isRecord(driver) && typeof driver.default === "function")
|
|
205
|
-
return driver.default;
|
|
206
|
-
return null;
|
|
207
|
-
}
|
|
208
|
-
async function loadDriver(injected) {
|
|
209
|
-
if (injected !== undefined && injected !== null) {
|
|
210
|
-
const factory = factoryOf(injected);
|
|
211
|
-
if (!factory)
|
|
212
|
-
throw new LocalRunError("DB_DRIVER_MISSING", "the injected postgres driver is not the postgres.js module");
|
|
213
|
-
return factory;
|
|
214
|
-
}
|
|
215
|
-
const specifier = "postgres";
|
|
216
|
-
let loaded;
|
|
217
|
-
try {
|
|
218
|
-
loaded = await import(specifier);
|
|
219
|
-
} catch {
|
|
220
|
-
throw new LocalRunError("DB_DRIVER_MISSING", "ctx.db needs the postgres driver: install it next to the connector (npm install --save-dev postgres)");
|
|
221
|
-
}
|
|
222
|
-
const factory = factoryOf(loaded);
|
|
223
|
-
if (!factory)
|
|
224
|
-
throw new LocalRunError("DB_DRIVER_MISSING", "the installed postgres package is not the postgres.js driver");
|
|
225
|
-
return factory;
|
|
226
|
-
}
|
|
227
|
-
function toParam(value) {
|
|
228
|
-
if (value === undefined)
|
|
229
|
-
return null;
|
|
230
|
-
if (isRecord(value))
|
|
231
|
-
return JSON.stringify(value);
|
|
232
|
-
return value;
|
|
233
|
-
}
|
|
234
|
-
function toJsonValue(value) {
|
|
235
|
-
if (value instanceof Date)
|
|
236
|
-
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
237
|
-
if (typeof value === "bigint")
|
|
238
|
-
return value.toString();
|
|
239
|
-
if (value instanceof Uint8Array)
|
|
240
|
-
return Buffer.from(value).toString("base64");
|
|
241
|
-
if (Array.isArray(value))
|
|
242
|
-
return value.map(toJsonValue);
|
|
243
|
-
return value;
|
|
244
|
-
}
|
|
245
|
-
function toRows(rows) {
|
|
246
|
-
return rows.map((row) => {
|
|
247
|
-
const out = {};
|
|
248
|
-
for (const [name, value] of Object.entries(row))
|
|
249
|
-
out[name] = toJsonValue(value);
|
|
250
|
-
return out;
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
class LocalDbError extends Error {
|
|
255
|
-
code;
|
|
256
|
-
constructor(code, message) {
|
|
257
|
-
super(message);
|
|
258
|
-
this.name = "LocalDbError";
|
|
259
|
-
this.code = code;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function createLocalDb(target, options) {
|
|
263
|
-
let pool = null;
|
|
264
|
-
const cursors = new Map;
|
|
265
|
-
const secrets = [...options.secrets, target.password];
|
|
266
|
-
const poolOf = async () => {
|
|
267
|
-
if (pool)
|
|
268
|
-
return pool;
|
|
269
|
-
const factory = await loadDriver(options.driver);
|
|
270
|
-
pool = factory({
|
|
271
|
-
host: target.host,
|
|
272
|
-
port: target.port,
|
|
273
|
-
database: target.database,
|
|
274
|
-
username: target.user,
|
|
275
|
-
password: target.password,
|
|
276
|
-
ssl: target.ssl ? "require" : false,
|
|
277
|
-
max: 4,
|
|
278
|
-
idle_timeout: 60,
|
|
279
|
-
connect_timeout: 10,
|
|
280
|
-
prepare: false,
|
|
281
|
-
onnotice: () => {},
|
|
282
|
-
connection: { application_name: APPLICATION_NAME },
|
|
283
|
-
types: {
|
|
284
|
-
rawText: { to: 25, from: RAW_TEXT_OIDS, serialize: (value) => String(value), parse: (value) => value }
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
return pool;
|
|
288
|
-
};
|
|
289
|
-
const failed = (error) => {
|
|
290
|
-
if (error instanceof LocalDbError || error instanceof LocalRunError)
|
|
291
|
-
return error;
|
|
292
|
-
const message = redactSecretValues(error instanceof Error ? error.message : String(error), secrets);
|
|
293
|
-
return new LocalDbError("DB_PROXY_FAILED", `The query to the source database failed: ${message}`);
|
|
294
|
-
};
|
|
295
|
-
const finish = async (id, cursor, ok) => {
|
|
296
|
-
cursors.delete(id);
|
|
297
|
-
try {
|
|
298
|
-
if (ok)
|
|
299
|
-
await cursor.iterator.return?.();
|
|
300
|
-
await cursor.reserved.unsafe(ok ? "commit" : "rollback");
|
|
301
|
-
} catch {
|
|
302
|
-
await Promise.resolve(cursor.reserved.unsafe("rollback")).then(undefined, () => {
|
|
303
|
-
return;
|
|
304
|
-
});
|
|
305
|
-
} finally {
|
|
306
|
-
cursor.reserved.release();
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
return {
|
|
310
|
-
async query(request) {
|
|
311
|
-
const sql = await poolOf();
|
|
312
|
-
let reserved = null;
|
|
313
|
-
try {
|
|
314
|
-
reserved = await sql.reserve();
|
|
315
|
-
await reserved.unsafe("begin read only");
|
|
316
|
-
await reserved.unsafe(`set local statement_timeout = ${STATEMENT_TIMEOUT_MS}`);
|
|
317
|
-
const iterator = reserved.unsafe(request.sql, request.params.map(toParam)).cursor(BATCH_ROWS)[Symbol.asyncIterator]();
|
|
318
|
-
const first = await iterator.next();
|
|
319
|
-
const rows = first.done ? [] : first.value;
|
|
320
|
-
const fields = (rows.columns ?? []).map((column) => ({ name: column.name, dataTypeId: column.type }));
|
|
321
|
-
const id = import_node_crypto.randomUUID();
|
|
322
|
-
const cursor = { reserved, iterator };
|
|
323
|
-
reserved = null;
|
|
324
|
-
if (first.done === true || rows.length < BATCH_ROWS) {
|
|
325
|
-
await finish(id, cursor, true);
|
|
326
|
-
return { cursorId: null, fields, rows: toRows(rows), done: true };
|
|
327
|
-
}
|
|
328
|
-
cursors.set(id, cursor);
|
|
329
|
-
return { cursorId: id, fields, rows: toRows(rows), done: false };
|
|
330
|
-
} catch (error) {
|
|
331
|
-
if (reserved) {
|
|
332
|
-
const held = reserved;
|
|
333
|
-
await Promise.resolve(held.unsafe("rollback")).then(undefined, () => {
|
|
334
|
-
return;
|
|
335
|
-
});
|
|
336
|
-
held.release();
|
|
337
|
-
}
|
|
338
|
-
throw failed(error);
|
|
339
|
-
}
|
|
340
|
-
},
|
|
341
|
-
async next(cursorId) {
|
|
342
|
-
const cursor = cursors.get(cursorId);
|
|
343
|
-
if (!cursor)
|
|
344
|
-
throw new LocalDbError("DB_CURSOR_NOT_FOUND", "The cursor doesn't exist or was closed");
|
|
345
|
-
try {
|
|
346
|
-
const step = await cursor.iterator.next();
|
|
347
|
-
const rows = step.done ? [] : step.value;
|
|
348
|
-
const done = step.done === true || rows.length < BATCH_ROWS;
|
|
349
|
-
if (done)
|
|
350
|
-
await finish(cursorId, cursor, true);
|
|
351
|
-
return { rows: toRows(rows), done };
|
|
352
|
-
} catch (error) {
|
|
353
|
-
await finish(cursorId, cursor, false);
|
|
354
|
-
throw failed(error);
|
|
355
|
-
}
|
|
356
|
-
},
|
|
357
|
-
async close(cursorId) {
|
|
358
|
-
const cursor = cursors.get(cursorId);
|
|
359
|
-
if (cursor)
|
|
360
|
-
await finish(cursorId, cursor, true);
|
|
361
|
-
},
|
|
362
|
-
async end() {
|
|
363
|
-
for (const [id, cursor] of [...cursors])
|
|
364
|
-
await finish(id, cursor, false);
|
|
365
|
-
const current = pool;
|
|
366
|
-
pool = null;
|
|
367
|
-
if (current)
|
|
368
|
-
await current.end({ timeout: 5 }).catch(() => {
|
|
369
|
-
return;
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// src/testing/pinned-http.ts
|
|
376
|
-
var import_node_http = require("node:http");
|
|
377
|
-
var import_node_https = require("node:https");
|
|
378
|
-
var import_node_zlib = require("node:zlib");
|
|
379
|
-
|
|
380
|
-
class PinnedBodyTooLargeError extends Error {
|
|
381
|
-
constructor() {
|
|
382
|
-
super("response body too large");
|
|
383
|
-
this.name = "PinnedBodyTooLargeError";
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
var NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
387
|
-
function pinnedLookup(addresses) {
|
|
388
|
-
return (_hostname, options, callback) => {
|
|
389
|
-
const all = typeof options === "object" && options !== null && options.all === true;
|
|
390
|
-
const first = addresses[0];
|
|
391
|
-
if (!first) {
|
|
392
|
-
callback(Object.assign(new Error("no address to connect to"), { code: "ENOTFOUND" }), []);
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
if (all)
|
|
396
|
-
callback(null, [...addresses]);
|
|
397
|
-
else
|
|
398
|
-
callback(null, first.address, first.family);
|
|
399
|
-
};
|
|
400
|
-
}
|
|
401
|
-
function decompress(bytes, encoding, maxBytes) {
|
|
402
|
-
const options = { maxOutputLength: maxBytes + 1 };
|
|
403
|
-
try {
|
|
404
|
-
switch (encoding) {
|
|
405
|
-
case "gzip":
|
|
406
|
-
case "x-gzip":
|
|
407
|
-
return import_node_zlib.gunzipSync(bytes, options);
|
|
408
|
-
case "deflate":
|
|
409
|
-
try {
|
|
410
|
-
return import_node_zlib.inflateSync(bytes, options);
|
|
411
|
-
} catch (error) {
|
|
412
|
-
if (error instanceof RangeError)
|
|
413
|
-
throw error;
|
|
414
|
-
return import_node_zlib.inflateRawSync(bytes, options);
|
|
415
|
-
}
|
|
416
|
-
case "br":
|
|
417
|
-
return import_node_zlib.brotliDecompressSync(bytes, options);
|
|
418
|
-
default:
|
|
419
|
-
return bytes;
|
|
420
|
-
}
|
|
421
|
-
} catch (error) {
|
|
422
|
-
if (error instanceof RangeError || error.code === "ERR_BUFFER_TOO_LARGE")
|
|
423
|
-
throw new PinnedBodyTooLargeError;
|
|
424
|
-
throw error;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
function toHeaders(response) {
|
|
428
|
-
const headers = new Headers;
|
|
429
|
-
const raw = response.rawHeaders;
|
|
430
|
-
for (let index = 0;index + 1 < raw.length; index += 2) {
|
|
431
|
-
try {
|
|
432
|
-
headers.append(raw[index] ?? "", raw[index + 1] ?? "");
|
|
433
|
-
} catch {}
|
|
434
|
-
}
|
|
435
|
-
return headers;
|
|
436
|
-
}
|
|
437
|
-
function pinnedRequest(input) {
|
|
438
|
-
const { url, method, signal, maxBytes } = input;
|
|
439
|
-
const send = url.protocol === "https:" ? import_node_https.request : import_node_http.request;
|
|
440
|
-
const body = input.body !== null && method !== "GET" && method !== "HEAD" ? Buffer.from(input.body) : null;
|
|
441
|
-
const headers = { ...input.headers };
|
|
442
|
-
if (body !== null)
|
|
443
|
-
headers["content-length"] = String(body.byteLength);
|
|
444
|
-
return new Promise((resolve, reject) => {
|
|
445
|
-
if (signal.aborted) {
|
|
446
|
-
reject(new Error("aborted"));
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
let settled = false;
|
|
450
|
-
const fail = (error) => {
|
|
451
|
-
if (settled)
|
|
452
|
-
return;
|
|
453
|
-
settled = true;
|
|
454
|
-
signal.removeEventListener("abort", onAbort);
|
|
455
|
-
reject(error);
|
|
456
|
-
};
|
|
457
|
-
const req = send(url, { method, headers, lookup: pinnedLookup(input.addresses) }, (response) => {
|
|
458
|
-
const chunks = [];
|
|
459
|
-
let size = 0;
|
|
460
|
-
response.on("data", (chunk) => {
|
|
461
|
-
size += chunk.byteLength;
|
|
462
|
-
if (size > maxBytes) {
|
|
463
|
-
req.destroy();
|
|
464
|
-
fail(new PinnedBodyTooLargeError);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
chunks.push(chunk);
|
|
468
|
-
});
|
|
469
|
-
response.on("error", (error) => fail(error));
|
|
470
|
-
response.on("end", () => {
|
|
471
|
-
if (settled)
|
|
472
|
-
return;
|
|
473
|
-
try {
|
|
474
|
-
const status = response.statusCode ?? 0;
|
|
475
|
-
const encoding = (response.headers["content-encoding"] ?? "").trim().toLowerCase();
|
|
476
|
-
const bytes = method === "HEAD" ? Buffer.alloc(0) : decompress(Buffer.concat(chunks), encoding, maxBytes);
|
|
477
|
-
const responseHeaders = toHeaders(response);
|
|
478
|
-
const body = NULL_BODY_STATUSES.has(status) || bytes.byteLength === 0 ? null : bytes;
|
|
479
|
-
const result = new Response(body, { status, headers: responseHeaders });
|
|
480
|
-
settled = true;
|
|
481
|
-
signal.removeEventListener("abort", onAbort);
|
|
482
|
-
resolve(result);
|
|
483
|
-
} catch (error) {
|
|
484
|
-
fail(error instanceof Error ? error : new Error(String(error)));
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
const onAbort = () => {
|
|
489
|
-
req.destroy(new Error("aborted"));
|
|
490
|
-
fail(new Error("aborted"));
|
|
491
|
-
};
|
|
492
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
493
|
-
req.on("error", (error) => fail(error));
|
|
494
|
-
req.end(body ?? undefined);
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// src/testing/gateway.ts
|
|
499
|
-
class LocalGatewayError extends Error {
|
|
500
|
-
code;
|
|
501
|
-
status;
|
|
502
|
-
constructor(code, message, status) {
|
|
503
|
-
super(message);
|
|
504
|
-
this.name = "LocalGatewayError";
|
|
505
|
-
this.code = code;
|
|
506
|
-
this.status = status;
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
var DEFAULT_TIMEOUT_MS = 30000;
|
|
510
|
-
var MAX_TIMEOUT_MS = 120000;
|
|
511
|
-
var RESPONSE_MAX_BYTES = 10 * 1024 * 1024;
|
|
512
|
-
var MAX_REDIRECTS = 5;
|
|
513
|
-
var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
514
|
-
var DROPPED_REQUEST_HEADERS = new Set(["host", "content-length", "connection", "transfer-encoding", "keep-alive", "upgrade"]);
|
|
515
|
-
var METADATA_ADDRESSES = new Set(["169.254.169.254", "100.100.100.200", "fd00:ec2::254"]);
|
|
516
|
-
var SECRET_FIELD = "[A-Za-z_][A-Za-z0-9_]*";
|
|
517
|
-
var PLAIN_HANDLE = new RegExp(`ontos:secret:(${SECRET_FIELD})`, "g");
|
|
518
|
-
var URL_HANDLE = new RegExp(`ontos(?::|%3[Aa])secret(?::|%3[Aa])(${SECRET_FIELD})`, "g");
|
|
519
|
-
function isRecord2(value) {
|
|
520
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
521
|
-
}
|
|
522
|
-
function errorMessage(error) {
|
|
523
|
-
return error instanceof Error ? error.message : String(error);
|
|
524
|
-
}
|
|
525
|
-
function sleep(ms) {
|
|
526
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
527
|
-
}
|
|
528
|
-
function normalizeHost(host) {
|
|
529
|
-
const value = host.trim().replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
530
|
-
const mapped = /^::ffff:(?:0:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(value);
|
|
531
|
-
if (mapped) {
|
|
532
|
-
const high = Number.parseInt(mapped[1] ?? "0", 16);
|
|
533
|
-
const low = Number.parseInt(mapped[2] ?? "0", 16);
|
|
534
|
-
return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
535
|
-
}
|
|
536
|
-
const dotted = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
|
|
537
|
-
if (dotted?.[1])
|
|
538
|
-
return dotted[1];
|
|
539
|
-
return value;
|
|
540
|
-
}
|
|
541
|
-
function matchHostPattern(host, pattern) {
|
|
542
|
-
const normalized = normalizeHost(pattern);
|
|
543
|
-
if (normalized.startsWith("*.")) {
|
|
544
|
-
const suffix = normalized.slice(1);
|
|
545
|
-
return suffix.length > 1 && host.length > suffix.length && host.endsWith(suffix);
|
|
546
|
-
}
|
|
547
|
-
return normalized !== "" && host === normalized;
|
|
548
|
-
}
|
|
549
|
-
function isMetadataAddress(host) {
|
|
550
|
-
return METADATA_ADDRESSES.has(normalizeHost(host));
|
|
551
|
-
}
|
|
552
|
-
function isIpLiteral(host) {
|
|
553
|
-
return /^\d+\.\d+\.\d+\.\d+$/.test(host) || host.includes(":");
|
|
554
|
-
}
|
|
555
|
-
function secretUnavailable(field) {
|
|
556
|
-
return new LocalGatewayError("EGRESS_SECRET_UNAVAILABLE", `Secret '${field}' is unavailable: it isn't configured locally`, 422);
|
|
557
|
-
}
|
|
558
|
-
function lookupSecret(values, field) {
|
|
559
|
-
const value = Object.hasOwn(values, field) ? values[field] : undefined;
|
|
560
|
-
if (typeof value !== "string")
|
|
561
|
-
throw secretUnavailable(field);
|
|
562
|
-
return value;
|
|
563
|
-
}
|
|
564
|
-
function substituteHandles(text, values) {
|
|
565
|
-
for (const match of text.matchAll(PLAIN_HANDLE))
|
|
566
|
-
lookupSecret(values, match[1] ?? "");
|
|
567
|
-
return text.replace(PLAIN_HANDLE, (_match, field) => lookupSecret(values, field));
|
|
568
|
-
}
|
|
569
|
-
function substituteUrlHandles(text, values) {
|
|
570
|
-
for (const match of text.matchAll(URL_HANDLE))
|
|
571
|
-
lookupSecret(values, match[1] ?? "");
|
|
572
|
-
return text.replace(URL_HANDLE, (_match, field) => encodeURIComponent(lookupSecret(values, field)));
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
class LocalLimiter {
|
|
576
|
-
perSecond;
|
|
577
|
-
concurrency;
|
|
578
|
-
nextAt = 0;
|
|
579
|
-
active = 0;
|
|
580
|
-
waiting = [];
|
|
581
|
-
constructor(perSecond, concurrency) {
|
|
582
|
-
this.perSecond = perSecond;
|
|
583
|
-
this.concurrency = concurrency;
|
|
584
|
-
}
|
|
585
|
-
async acquire() {
|
|
586
|
-
if (this.active < this.concurrency && this.waiting.length === 0) {
|
|
587
|
-
this.active += 1;
|
|
588
|
-
} else {
|
|
589
|
-
await new Promise((resolve) => this.waiting.push(resolve));
|
|
590
|
-
}
|
|
591
|
-
const now = Date.now();
|
|
592
|
-
const at = Math.max(now, this.nextAt);
|
|
593
|
-
this.nextAt = at + 1000 / this.perSecond;
|
|
594
|
-
if (at > now)
|
|
595
|
-
await sleep(at - now);
|
|
596
|
-
let released = false;
|
|
597
|
-
return () => {
|
|
598
|
-
if (released)
|
|
599
|
-
return;
|
|
600
|
-
released = true;
|
|
601
|
-
const next = this.waiting.shift();
|
|
602
|
-
if (next)
|
|
603
|
-
next();
|
|
604
|
-
else
|
|
605
|
-
this.active -= 1;
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
function parseHttpRequest(payload) {
|
|
610
|
-
if (!isRecord2(payload) || typeof payload.url !== "string" || typeof payload.method !== "string") {
|
|
611
|
-
throw new LocalGatewayError("BAD_REQUEST", "http request must have a method and a url", 400);
|
|
612
|
-
}
|
|
613
|
-
return payload;
|
|
614
|
-
}
|
|
615
|
-
function scriptUrlOf(request) {
|
|
616
|
-
let url;
|
|
617
|
-
try {
|
|
618
|
-
url = new URL(request.url);
|
|
619
|
-
} catch {
|
|
620
|
-
throw new LocalGatewayError("BAD_REQUEST", "url must be an absolute URL", 400);
|
|
621
|
-
}
|
|
622
|
-
for (const [name, value] of Object.entries(request.query ?? {})) {
|
|
623
|
-
for (const item of Array.isArray(value) ? value : [value])
|
|
624
|
-
url.searchParams.append(name, item);
|
|
625
|
-
}
|
|
626
|
-
return url;
|
|
627
|
-
}
|
|
628
|
-
var utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
629
|
-
function encodeBody(bytes) {
|
|
630
|
-
try {
|
|
631
|
-
return { body: utf8.decode(bytes), bodyEncoding: "utf8" };
|
|
632
|
-
} catch {
|
|
633
|
-
return { body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" };
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
function tooLarge() {
|
|
637
|
-
return new LocalGatewayError("EGRESS_RESPONSE_TOO_LARGE", `The external system's response exceeds the ${RESPONSE_MAX_BYTES / 1024 / 1024} MiB limit`, 502);
|
|
638
|
-
}
|
|
639
|
-
function hostDenied(host) {
|
|
640
|
-
return new LocalRunError("HOST_DENIED", `Access to host '${host}' is denied: add it to the connector's hosts and the local configuration`);
|
|
641
|
-
}
|
|
642
|
-
function computeCrypto(request, values) {
|
|
643
|
-
const data = substituteHandles(request.data, values);
|
|
644
|
-
switch (request.op) {
|
|
645
|
-
case "hash":
|
|
646
|
-
return { value: import_node_crypto2.createHash(request.algorithm).update(data, "utf8").digest(request.encoding) };
|
|
647
|
-
case "hmac":
|
|
648
|
-
return { value: import_node_crypto2.createHmac(request.algorithm, substituteHandles(request.key, values)).update(data, "utf8").digest(request.encoding) };
|
|
649
|
-
case "sign": {
|
|
650
|
-
try {
|
|
651
|
-
return { value: import_node_crypto2.sign("sha256", Buffer.from(data, "utf8"), substituteHandles(request.key, values)).toString(request.encoding) };
|
|
652
|
-
} catch (error) {
|
|
653
|
-
if (error instanceof LocalGatewayError)
|
|
654
|
-
throw error;
|
|
655
|
-
throw new LocalGatewayError("BAD_REQUEST", "key is not a valid RSA private key", 400);
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
default:
|
|
659
|
-
throw new LocalGatewayError("BAD_REQUEST", "op must be one of hmac, sign, hash", 400);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
function createLocalGateway(options) {
|
|
663
|
-
const secrets = { ...options.secrets };
|
|
664
|
-
const secretTexts = secretRedactionTexts([...Object.values(secrets), ...options.db?.password ? [options.db.password] : []]);
|
|
665
|
-
const redact = (text) => redactSecretValues(text, secretTexts);
|
|
666
|
-
const hostPatterns = [...options.hosts];
|
|
667
|
-
const doFetch = options.fetch ?? fetch;
|
|
668
|
-
const realNetwork = options.fetch === undefined;
|
|
669
|
-
const resolveHost = options.resolve ?? ((host) => import_promises.lookup(host, { all: true, verbatim: true }));
|
|
670
|
-
const limiter = options.rateLimit && options.rateLimit.perSecond > 0 ? new LocalLimiter(options.rateLimit.perSecond, Math.max(1, Math.floor(options.rateLimit.concurrency || 1))) : null;
|
|
671
|
-
const cache = new Map;
|
|
672
|
-
let db = null;
|
|
673
|
-
const exchange = (entry) => {
|
|
674
|
-
try {
|
|
675
|
-
options.onExchange?.(entry);
|
|
676
|
-
} catch {}
|
|
677
|
-
};
|
|
678
|
-
const checkHost = async (url) => {
|
|
679
|
-
const host = normalizeHost(url.hostname);
|
|
680
|
-
if (isMetadataAddress(host))
|
|
681
|
-
throw hostDenied(host);
|
|
682
|
-
const allowed = hostPatterns.some((pattern) => matchHostPattern(host, pattern) || normalizeHost(pattern) === url.host.toLowerCase());
|
|
683
|
-
if (!allowed)
|
|
684
|
-
throw hostDenied(host);
|
|
685
|
-
if (!realNetwork)
|
|
686
|
-
return null;
|
|
687
|
-
if (isIpLiteral(host))
|
|
688
|
-
return [{ address: host, family: host.includes(":") ? 6 : 4 }];
|
|
689
|
-
let addresses;
|
|
690
|
-
try {
|
|
691
|
-
addresses = await resolveHost(host);
|
|
692
|
-
} catch (error) {
|
|
693
|
-
throw new LocalGatewayError("EGRESS_UPSTREAM_FAILED", redact(`The outbound request failed: cannot resolve ${host}: ${errorMessage(error)}`), 502);
|
|
694
|
-
}
|
|
695
|
-
if (addresses.length === 0)
|
|
696
|
-
throw new LocalGatewayError("EGRESS_UPSTREAM_FAILED", `The outbound request failed: cannot resolve ${host}`, 502);
|
|
697
|
-
if (addresses.some((record) => isMetadataAddress(record.address)))
|
|
698
|
-
throw hostDenied(host);
|
|
699
|
-
return addresses;
|
|
700
|
-
};
|
|
701
|
-
const sendOnce = async (url, method, headers, body, addresses, signal) => {
|
|
702
|
-
try {
|
|
703
|
-
if (addresses !== null)
|
|
704
|
-
return await pinnedRequest({ url, method, headers, body, addresses, signal, maxBytes: RESPONSE_MAX_BYTES });
|
|
705
|
-
return await doFetch(url.toString(), {
|
|
706
|
-
method,
|
|
707
|
-
headers,
|
|
708
|
-
...body !== null && method !== "GET" && method !== "HEAD" ? { body } : {},
|
|
709
|
-
redirect: "manual",
|
|
710
|
-
signal
|
|
711
|
-
});
|
|
712
|
-
} catch (error) {
|
|
713
|
-
if (error instanceof PinnedBodyTooLargeError)
|
|
714
|
-
throw tooLarge();
|
|
715
|
-
if (signal.aborted)
|
|
716
|
-
throw new LocalGatewayError("EGRESS_TIMEOUT", "The outbound request timed out", 504);
|
|
717
|
-
throw new LocalGatewayError("EGRESS_UPSTREAM_FAILED", redact(`The outbound request failed: ${errorMessage(error)}`), 502);
|
|
718
|
-
}
|
|
719
|
-
};
|
|
720
|
-
const http = async (payload) => {
|
|
721
|
-
const request = parseHttpRequest(payload);
|
|
722
|
-
const scriptUrl = scriptUrlOf(request);
|
|
723
|
-
let url;
|
|
724
|
-
try {
|
|
725
|
-
url = new URL(substituteUrlHandles(scriptUrl.toString(), secrets));
|
|
726
|
-
} catch (error) {
|
|
727
|
-
if (error instanceof LocalGatewayError)
|
|
728
|
-
throw error;
|
|
729
|
-
throw new LocalGatewayError("BAD_REQUEST", "url is not valid after substituting secrets", 400);
|
|
730
|
-
}
|
|
731
|
-
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
732
|
-
throw new LocalGatewayError("EGRESS_SCHEME_DENIED", "Only http(s) outbound requests are allowed", 403);
|
|
733
|
-
let addresses = await checkHost(url);
|
|
734
|
-
let headers = {};
|
|
735
|
-
for (const [name, value] of Object.entries(request.headers ?? {})) {
|
|
736
|
-
if (DROPPED_REQUEST_HEADERS.has(name.toLowerCase()))
|
|
737
|
-
continue;
|
|
738
|
-
headers[name] = substituteHandles(value, secrets);
|
|
739
|
-
}
|
|
740
|
-
let body = null;
|
|
741
|
-
if (request.body !== null && request.body !== undefined) {
|
|
742
|
-
body = request.bodyEncoding === "base64" ? Buffer.from(request.body, "base64") : Buffer.from(substituteHandles(request.body, secrets), "utf8");
|
|
743
|
-
}
|
|
744
|
-
const timeoutMs = Math.min(request.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
745
|
-
const release = limiter ? await limiter.acquire() : () => {
|
|
746
|
-
return;
|
|
747
|
-
};
|
|
748
|
-
const controller = new AbortController;
|
|
749
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
750
|
-
try {
|
|
751
|
-
let method = request.method;
|
|
752
|
-
let redirects = 0;
|
|
753
|
-
for (;; ) {
|
|
754
|
-
const response = await sendOnce(url, method, headers, body, addresses, controller.signal);
|
|
755
|
-
const location = response.headers.get("location");
|
|
756
|
-
if (REDIRECT_STATUSES.has(response.status) && location !== null && redirects < MAX_REDIRECTS) {
|
|
757
|
-
let next = null;
|
|
758
|
-
try {
|
|
759
|
-
next = new URL(location, url);
|
|
760
|
-
} catch {
|
|
761
|
-
next = null;
|
|
762
|
-
}
|
|
763
|
-
if (next && next.host === url.host && (next.protocol === "https:" || next.protocol === "http:")) {
|
|
764
|
-
redirects += 1;
|
|
765
|
-
if (response.status === 303 ? method !== "HEAD" : (response.status === 301 || response.status === 302) && method !== "GET" && method !== "HEAD") {
|
|
766
|
-
method = "GET";
|
|
767
|
-
body = null;
|
|
768
|
-
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-type"));
|
|
769
|
-
}
|
|
770
|
-
url = next;
|
|
771
|
-
addresses = await checkHost(url);
|
|
772
|
-
continue;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
let bytes;
|
|
776
|
-
try {
|
|
777
|
-
bytes = method === "HEAD" ? new Uint8Array : new Uint8Array(await response.arrayBuffer());
|
|
778
|
-
} catch (error) {
|
|
779
|
-
if (controller.signal.aborted)
|
|
780
|
-
throw new LocalGatewayError("EGRESS_TIMEOUT", "The outbound request timed out", 504);
|
|
781
|
-
throw new LocalGatewayError("EGRESS_UPSTREAM_FAILED", redact(`Reading the response failed: ${errorMessage(error)}`), 502);
|
|
782
|
-
}
|
|
783
|
-
if (bytes.byteLength > RESPONSE_MAX_BYTES)
|
|
784
|
-
throw tooLarge();
|
|
785
|
-
const responseHeaders = {};
|
|
786
|
-
response.headers.forEach((value, name) => {
|
|
787
|
-
const lower = name.toLowerCase();
|
|
788
|
-
if (lower === "content-encoding" || lower === "content-length")
|
|
789
|
-
return;
|
|
790
|
-
responseHeaders[lower] = value;
|
|
791
|
-
});
|
|
792
|
-
return {
|
|
793
|
-
status: response.status,
|
|
794
|
-
url: redirects === 0 ? scriptUrl.toString() : url.toString(),
|
|
795
|
-
headers: responseHeaders,
|
|
796
|
-
...encodeBody(bytes)
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
} finally {
|
|
800
|
-
clearTimeout(timer);
|
|
801
|
-
release();
|
|
802
|
-
}
|
|
803
|
-
};
|
|
804
|
-
const redactResponse = (response) => ({
|
|
805
|
-
...response,
|
|
806
|
-
url: redact(response.url),
|
|
807
|
-
headers: Object.fromEntries(Object.entries(response.headers).map(([name, value]) => [name, redact(value)])),
|
|
808
|
-
body: response.bodyEncoding === "utf8" ? redact(response.body) : response.body
|
|
809
|
-
});
|
|
810
|
-
const dbOf = () => {
|
|
811
|
-
if (!options.db)
|
|
812
|
-
throw new LocalGatewayError("DB_PROXY_NOT_DECLARED", "ctx.db is not configured locally: set db in the local configuration", 403);
|
|
813
|
-
if (isMetadataAddress(options.db.host))
|
|
814
|
-
throw hostDenied(normalizeHost(options.db.host));
|
|
815
|
-
db ??= createLocalDb(options.db, { driver: options.postgres, secrets: Object.values(secrets) });
|
|
816
|
-
return db;
|
|
817
|
-
};
|
|
818
|
-
const traced = async (kind, request, run) => {
|
|
819
|
-
const startedAt = Date.now();
|
|
820
|
-
try {
|
|
821
|
-
const response = await run();
|
|
822
|
-
exchange({ kind, request, response, error: null, durationMs: Date.now() - startedAt });
|
|
823
|
-
return response;
|
|
824
|
-
} catch (error) {
|
|
825
|
-
const code = isRecord2(error) && typeof error.code === "string" ? error.code : "HOST_CALL_FAILED";
|
|
826
|
-
exchange({ kind, request, response: null, error: { code, message: errorMessage(error) }, durationMs: Date.now() - startedAt });
|
|
827
|
-
throw error;
|
|
828
|
-
}
|
|
829
|
-
};
|
|
830
|
-
const call = async (method, payload) => {
|
|
831
|
-
switch (method) {
|
|
832
|
-
case "http":
|
|
833
|
-
return redactResponse(await traced("http", payload, () => http(payload)));
|
|
834
|
-
case "db.query": {
|
|
835
|
-
const request = payload;
|
|
836
|
-
return traced("db", request, () => dbOf().query({ sql: request.sql, params: request.params ?? [] }));
|
|
837
|
-
}
|
|
838
|
-
case "db.next": {
|
|
839
|
-
const cursorId = isRecord2(payload) && typeof payload.cursorId === "string" ? payload.cursorId : "";
|
|
840
|
-
return traced("db", { cursorId }, () => dbOf().next(cursorId));
|
|
841
|
-
}
|
|
842
|
-
case "db.close": {
|
|
843
|
-
const cursorId = isRecord2(payload) && typeof payload.cursorId === "string" ? payload.cursorId : "";
|
|
844
|
-
await dbOf().close(cursorId);
|
|
845
|
-
return null;
|
|
846
|
-
}
|
|
847
|
-
case "crypto":
|
|
848
|
-
return traced("crypto", payload, async () => computeCrypto(payload, secrets));
|
|
849
|
-
case "cache.get": {
|
|
850
|
-
const key = isRecord2(payload) && typeof payload.key === "string" ? payload.key : "";
|
|
851
|
-
const entry = cache.get(key);
|
|
852
|
-
if (!entry || Date.parse(entry.expiresAt) <= Date.now())
|
|
853
|
-
return null;
|
|
854
|
-
return entry;
|
|
855
|
-
}
|
|
856
|
-
case "cache.set": {
|
|
857
|
-
if (!isRecord2(payload) || typeof payload.key !== "string" || typeof payload.value !== "string" || typeof payload.ttlSeconds !== "number") {
|
|
858
|
-
throw new LocalGatewayError("BAD_REQUEST", "cache.set needs key, value and ttlSeconds", 400);
|
|
859
|
-
}
|
|
860
|
-
cache.set(payload.key, { value: payload.value, expiresAt: new Date(Date.now() + payload.ttlSeconds * 1000).toISOString() });
|
|
861
|
-
return null;
|
|
862
|
-
}
|
|
863
|
-
case "secret.reveal": {
|
|
864
|
-
const field = isRecord2(payload) && typeof payload.field === "string" ? payload.field : "";
|
|
865
|
-
return { value: lookupSecret(secrets, field) };
|
|
866
|
-
}
|
|
867
|
-
default:
|
|
868
|
-
throw new LocalGatewayError("HOST_METHOD_UNSUPPORTED", `the local gateway does not answer ${method}`);
|
|
869
|
-
}
|
|
870
|
-
};
|
|
871
|
-
return {
|
|
872
|
-
call,
|
|
873
|
-
redact,
|
|
874
|
-
async close() {
|
|
875
|
-
const current = db;
|
|
876
|
-
db = null;
|
|
877
|
-
if (current)
|
|
878
|
-
await current.end();
|
|
879
|
-
}
|
|
880
|
-
};
|
|
881
|
-
}
|
|
882
|
-
// src/testing/engine.ts
|
|
883
|
-
var import_node_child_process = require("node:child_process");
|
|
884
|
-
var import_node_path3 = __toESM(require("node:path"), 1);
|
|
885
|
-
|
|
886
|
-
// src/internal/location.ts
|
|
887
|
-
var import_node_path = __toESM(require("node:path"), 1);
|
|
888
|
-
var IS_SOURCE = false;
|
|
889
|
-
var PACKAGE_ROOT = import_node_path.default.resolve(__dirname, "..");
|
|
890
|
-
function moduleFile(relative) {
|
|
891
|
-
return import_node_path.default.join(PACKAGE_ROOT, relative + ".js");
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
// src/runtime/guest-runtime.ts
|
|
895
|
-
var import_promises2 = require("node:fs/promises");
|
|
896
|
-
var import_node_path2 = __toESM(require("node:path"), 1);
|
|
897
|
-
var GUEST_ENTRY_PATH = moduleFile("runtime/guest/index");
|
|
898
|
-
var GUEST_BUNDLE_FILE = "guest.cjs";
|
|
899
|
-
var CODE_FILE = /\.[cm]?[jt]sx?$/;
|
|
900
|
-
function readSourcePlugin(bun) {
|
|
901
|
-
return {
|
|
902
|
-
name: "datasense-connector-read-source",
|
|
903
|
-
setup(build) {
|
|
904
|
-
build.onLoad({ filter: CODE_FILE }, async (args) => {
|
|
905
|
-
const extension = args.path.slice(args.path.lastIndexOf(".") + 1);
|
|
906
|
-
const loader = extension === "tsx" ? "tsx" : extension === "jsx" ? "jsx" : /^[cm]?js$/.test(extension) ? "js" : "ts";
|
|
907
|
-
return { contents: await bun.file(args.path).text(), loader };
|
|
908
|
-
});
|
|
909
|
-
}
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
var MACROS_OFF = { macros: false };
|
|
913
|
-
async function bundleGuestWithBun(entry = GUEST_ENTRY_PATH) {
|
|
914
|
-
const bun = globalThis.Bun;
|
|
915
|
-
if (!bun)
|
|
916
|
-
throw new Error("the guest runtime can only be bundled from source under Bun; use the built package (dist) under Node.js");
|
|
917
|
-
const result = await bun.build({
|
|
918
|
-
entrypoints: [entry],
|
|
919
|
-
format: "cjs",
|
|
920
|
-
target: "browser",
|
|
921
|
-
minify: false,
|
|
922
|
-
external: [],
|
|
923
|
-
plugins: [readSourcePlugin(bun)],
|
|
924
|
-
throw: false,
|
|
925
|
-
...MACROS_OFF
|
|
926
|
-
});
|
|
927
|
-
const output = result.outputs.find((item) => item.kind === "entry-point");
|
|
928
|
-
if (!result.success || !output) {
|
|
929
|
-
throw new Error(`guest runtime bundle failed: ${result.logs.map((log) => log.message).join("; ") || "no output"}`);
|
|
930
|
-
}
|
|
931
|
-
return output.text();
|
|
932
|
-
}
|
|
933
|
-
var cached = null;
|
|
934
|
-
function loadGuestRuntime() {
|
|
935
|
-
if (cached)
|
|
936
|
-
return cached;
|
|
937
|
-
const pending = IS_SOURCE ? bundleGuestWithBun() : import_promises2.readFile(import_node_path2.default.join(PACKAGE_ROOT, GUEST_BUNDLE_FILE), "utf8");
|
|
938
|
-
cached = pending;
|
|
939
|
-
pending.catch(() => {
|
|
940
|
-
if (cached === pending)
|
|
941
|
-
cached = null;
|
|
942
|
-
});
|
|
943
|
-
return pending;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// src/runtime/protocol.ts
|
|
947
|
-
var HOST_METHODS = Object.freeze([
|
|
948
|
-
"definition",
|
|
949
|
-
"declaration",
|
|
950
|
-
"http",
|
|
951
|
-
"db.query",
|
|
952
|
-
"db.next",
|
|
953
|
-
"db.close",
|
|
954
|
-
"crypto",
|
|
955
|
-
"cache.get",
|
|
956
|
-
"cache.set",
|
|
957
|
-
"secret.reveal",
|
|
958
|
-
"emit"
|
|
959
|
-
]);
|
|
960
|
-
var GUEST_FAILURE_CODES = Object.freeze([
|
|
961
|
-
"CONNECTOR_LOAD_FAILED",
|
|
962
|
-
"CONFIG_INVALID",
|
|
963
|
-
"SCHEMA_ERROR",
|
|
964
|
-
"FATAL_ERROR",
|
|
965
|
-
"AUTH_ERROR",
|
|
966
|
-
"TRANSIENT_ERROR",
|
|
967
|
-
"PUSH_UNKNOWN",
|
|
968
|
-
"CANCELED"
|
|
969
|
-
]);
|
|
970
|
-
var HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
|
|
971
|
-
var BODY_ENCODINGS = new Set(["utf8", "base64"]);
|
|
972
|
-
var DIGEST_ENCODINGS = new Set(["hex", "base64"]);
|
|
973
|
-
var HMAC_ALGORITHMS = new Set(["sha1", "sha256", "sha512"]);
|
|
974
|
-
var HASH_ALGORITHMS = new Set(["md5", "sha1", "sha256", "sha512"]);
|
|
975
|
-
var LOG_LEVELS = new Set(["info", "warn", "error"]);
|
|
976
|
-
var SYNC_MODES = new Set(["full", "incremental"]);
|
|
977
|
-
var SNAPSHOTS = new Set(["complete", "partial"]);
|
|
978
|
-
var HOST_METHOD_SET = new Set(HOST_METHODS);
|
|
979
|
-
var FAILURE_CODE_SET = new Set(GUEST_FAILURE_CODES);
|
|
980
|
-
function isRecord3(value) {
|
|
981
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
982
|
-
}
|
|
983
|
-
function isStringArray(value) {
|
|
984
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
985
|
-
}
|
|
986
|
-
function isStringRecord(value) {
|
|
987
|
-
return isRecord3(value) && Object.values(value).every((item) => typeof item === "string");
|
|
988
|
-
}
|
|
989
|
-
function isId(value) {
|
|
990
|
-
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
991
|
-
}
|
|
992
|
-
function parsePushRequest(value) {
|
|
993
|
-
if (!isRecord3(value))
|
|
994
|
-
return null;
|
|
995
|
-
const { requestId, push, receivedAt, headers, body } = value;
|
|
996
|
-
if (typeof requestId !== "string" || typeof push !== "string" || typeof receivedAt !== "string" || !isStringRecord(headers) || typeof body !== "string")
|
|
997
|
-
return null;
|
|
998
|
-
return { requestId, push, receivedAt, headers: { ...headers }, body };
|
|
999
|
-
}
|
|
1000
|
-
function parseJob(value) {
|
|
1001
|
-
if (!isRecord3(value) || !isRecord3(value.config) || !isStringArray(value.secretFields))
|
|
1002
|
-
return null;
|
|
1003
|
-
const config = value.config;
|
|
1004
|
-
const secretFields = [...value.secretFields];
|
|
1005
|
-
if (value.kind === "push") {
|
|
1006
|
-
if (!Array.isArray(value.requests))
|
|
1007
|
-
return null;
|
|
1008
|
-
const requests = [];
|
|
1009
|
-
for (const item of value.requests) {
|
|
1010
|
-
const request = parsePushRequest(item);
|
|
1011
|
-
if (!request)
|
|
1012
|
-
return null;
|
|
1013
|
-
requests.push(request);
|
|
1014
|
-
}
|
|
1015
|
-
return { kind: "push", config, secretFields, requests };
|
|
1016
|
-
}
|
|
1017
|
-
if (value.kind === "discover")
|
|
1018
|
-
return { kind: "discover", config, secretFields };
|
|
1019
|
-
if (value.kind === "check")
|
|
1020
|
-
return { kind: "check", config, secretFields };
|
|
1021
|
-
if (value.kind === "pull") {
|
|
1022
|
-
const { stream, mode, streamConfig, tables, discoverFirst } = value;
|
|
1023
|
-
if (typeof stream !== "string" || typeof mode !== "string" || !SYNC_MODES.has(mode) || !isRecord3(streamConfig) || !isStringArray(tables))
|
|
1024
|
-
return null;
|
|
1025
|
-
if (discoverFirst !== undefined && typeof discoverFirst !== "boolean")
|
|
1026
|
-
return null;
|
|
1027
|
-
return {
|
|
1028
|
-
kind: "pull",
|
|
1029
|
-
config,
|
|
1030
|
-
secretFields,
|
|
1031
|
-
stream,
|
|
1032
|
-
mode,
|
|
1033
|
-
state: value.state,
|
|
1034
|
-
streamConfig,
|
|
1035
|
-
tables: [...tables],
|
|
1036
|
-
...discoverFirst === true ? { discoverFirst: true } : {}
|
|
1037
|
-
};
|
|
1038
|
-
}
|
|
1039
|
-
return null;
|
|
1040
|
-
}
|
|
1041
|
-
function parseHostError(value) {
|
|
1042
|
-
if (!isRecord3(value) || typeof value.code !== "string" || typeof value.message !== "string")
|
|
1043
|
-
return null;
|
|
1044
|
-
const error = { code: value.code, message: value.message };
|
|
1045
|
-
if (value.status !== undefined) {
|
|
1046
|
-
if (typeof value.status !== "number" || !Number.isInteger(value.status))
|
|
1047
|
-
return null;
|
|
1048
|
-
error.status = value.status;
|
|
1049
|
-
}
|
|
1050
|
-
if (value.retryAfterMs !== undefined) {
|
|
1051
|
-
if (typeof value.retryAfterMs !== "number" || !Number.isFinite(value.retryAfterMs) || value.retryAfterMs < 0)
|
|
1052
|
-
return null;
|
|
1053
|
-
error.retryAfterMs = value.retryAfterMs;
|
|
1054
|
-
}
|
|
1055
|
-
return error;
|
|
1056
|
-
}
|
|
1057
|
-
function parseParentMessage(value) {
|
|
1058
|
-
if (!isRecord3(value))
|
|
1059
|
-
return null;
|
|
1060
|
-
switch (value.type) {
|
|
1061
|
-
case "execute": {
|
|
1062
|
-
const { guest, code, expect } = value;
|
|
1063
|
-
if (typeof guest !== "string" || typeof code !== "string")
|
|
1064
|
-
return null;
|
|
1065
|
-
if (!isRecord3(expect) || typeof expect.name !== "string" || typeof expect.version !== "string")
|
|
1066
|
-
return null;
|
|
1067
|
-
const job = parseJob(value.job);
|
|
1068
|
-
return job ? { type: "execute", guest, code, expect: { name: expect.name, version: expect.version }, job } : null;
|
|
1069
|
-
}
|
|
1070
|
-
case "reply": {
|
|
1071
|
-
if (!isId(value.id))
|
|
1072
|
-
return null;
|
|
1073
|
-
if (value.ok === true)
|
|
1074
|
-
return { type: "reply", id: value.id, ok: true, value: value.value };
|
|
1075
|
-
if (value.ok !== false)
|
|
1076
|
-
return null;
|
|
1077
|
-
const error = parseHostError(value.error);
|
|
1078
|
-
return error ? { type: "reply", id: value.id, ok: false, error } : null;
|
|
1079
|
-
}
|
|
1080
|
-
case "cancel":
|
|
1081
|
-
return { type: "cancel" };
|
|
1082
|
-
default:
|
|
1083
|
-
return null;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
function parseLogs(value) {
|
|
1087
|
-
if (!Array.isArray(value))
|
|
1088
|
-
return null;
|
|
1089
|
-
const logs = [];
|
|
1090
|
-
for (const entry of value) {
|
|
1091
|
-
if (!isRecord3(entry) || typeof entry.at !== "string" || typeof entry.level !== "string" || !LOG_LEVELS.has(entry.level) || typeof entry.message !== "string")
|
|
1092
|
-
return null;
|
|
1093
|
-
if (entry.requestId !== undefined && typeof entry.requestId !== "string")
|
|
1094
|
-
return null;
|
|
1095
|
-
logs.push({
|
|
1096
|
-
at: entry.at,
|
|
1097
|
-
level: entry.level,
|
|
1098
|
-
message: entry.message,
|
|
1099
|
-
...entry.requestId !== undefined ? { requestId: entry.requestId } : {}
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
return logs;
|
|
1103
|
-
}
|
|
1104
|
-
function parseSummary(value) {
|
|
1105
|
-
if (!isRecord3(value))
|
|
1106
|
-
return null;
|
|
1107
|
-
const { name, version, hosts, db, rateLimit, pushes, streams, secretFields, exposeToScript, config } = value;
|
|
1108
|
-
if (typeof name !== "string" || typeof version !== "string")
|
|
1109
|
-
return null;
|
|
1110
|
-
if (!isStringArray(hosts) || !isStringArray(pushes) || !isStringArray(streams) || !isStringArray(secretFields) || !isStringArray(exposeToScript))
|
|
1111
|
-
return null;
|
|
1112
|
-
if (db !== null && !(isRecord3(db) && db.engine === "postgresql"))
|
|
1113
|
-
return null;
|
|
1114
|
-
let parsedRateLimit = null;
|
|
1115
|
-
if (rateLimit !== null) {
|
|
1116
|
-
if (!isRecord3(rateLimit) || typeof rateLimit.perSecond !== "number" || typeof rateLimit.concurrency !== "number")
|
|
1117
|
-
return null;
|
|
1118
|
-
parsedRateLimit = { perSecond: rateLimit.perSecond, concurrency: rateLimit.concurrency };
|
|
1119
|
-
}
|
|
1120
|
-
return {
|
|
1121
|
-
name,
|
|
1122
|
-
version,
|
|
1123
|
-
hosts: [...hosts],
|
|
1124
|
-
db: db === null ? null : { engine: "postgresql" },
|
|
1125
|
-
rateLimit: parsedRateLimit,
|
|
1126
|
-
pushes: [...pushes],
|
|
1127
|
-
streams: [...streams],
|
|
1128
|
-
secretFields: [...secretFields],
|
|
1129
|
-
exposeToScript: [...exposeToScript],
|
|
1130
|
-
...isRecord3(config) ? { config } : {}
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
function parseTables(value) {
|
|
1134
|
-
if (!isRecord3(value))
|
|
1135
|
-
return null;
|
|
1136
|
-
const tables = {};
|
|
1137
|
-
for (const [name, table] of Object.entries(value)) {
|
|
1138
|
-
if (!isRecord3(table) || !isRecord3(table.columns) || !isStringArray(table.primaryKey))
|
|
1139
|
-
return null;
|
|
1140
|
-
tables[name] = table;
|
|
1141
|
-
}
|
|
1142
|
-
return tables;
|
|
1143
|
-
}
|
|
1144
|
-
function parseDiscover(value) {
|
|
1145
|
-
if (!isRecord3(value) || !isRecord3(value.streams) || !isRecord3(value.pushes))
|
|
1146
|
-
return null;
|
|
1147
|
-
const result = { streams: {}, pushes: {} };
|
|
1148
|
-
for (const [name, stream] of Object.entries(value.streams)) {
|
|
1149
|
-
if (!isRecord3(stream) || !isStringArray(stream.syncModes) || !stream.syncModes.every((mode) => SYNC_MODES.has(mode)))
|
|
1150
|
-
return null;
|
|
1151
|
-
if (typeof stream.snapshot !== "string" || !SNAPSHOTS.has(stream.snapshot))
|
|
1152
|
-
return null;
|
|
1153
|
-
if (stream.params !== undefined && !(isRecord3(stream.params) && stream.params.kind === "object"))
|
|
1154
|
-
return null;
|
|
1155
|
-
const tables = parseTables(stream.tables);
|
|
1156
|
-
if (!tables)
|
|
1157
|
-
return null;
|
|
1158
|
-
result.streams[name] = {
|
|
1159
|
-
tables,
|
|
1160
|
-
syncModes: [...stream.syncModes],
|
|
1161
|
-
snapshot: stream.snapshot,
|
|
1162
|
-
...stream.params !== undefined ? { params: stream.params } : {}
|
|
1163
|
-
};
|
|
1164
|
-
}
|
|
1165
|
-
for (const [name, push] of Object.entries(value.pushes)) {
|
|
1166
|
-
if (!isRecord3(push))
|
|
1167
|
-
return null;
|
|
1168
|
-
const tables = parseTables(push.tables);
|
|
1169
|
-
if (!tables)
|
|
1170
|
-
return null;
|
|
1171
|
-
result.pushes[name] = { tables };
|
|
1172
|
-
}
|
|
1173
|
-
return result;
|
|
1174
|
-
}
|
|
1175
|
-
function parsePushResult(value, requestIds) {
|
|
1176
|
-
const { records, removals, rejected } = value;
|
|
1177
|
-
if (!Array.isArray(records) || !Array.isArray(removals) || !Array.isArray(rejected))
|
|
1178
|
-
return null;
|
|
1179
|
-
const logs = parseLogs(value.logs);
|
|
1180
|
-
if (!logs)
|
|
1181
|
-
return null;
|
|
1182
|
-
const result = { kind: "push", records: [], removals: [], rejected: [], logs };
|
|
1183
|
-
for (const item of records) {
|
|
1184
|
-
if (!isRecord3(item) || typeof item.requestId !== "string" || !requestIds.has(item.requestId))
|
|
1185
|
-
return null;
|
|
1186
|
-
if (typeof item.table !== "string" || !isRecord3(item.row))
|
|
1187
|
-
return null;
|
|
1188
|
-
result.records.push({ requestId: item.requestId, table: item.table, row: item.row });
|
|
1189
|
-
}
|
|
1190
|
-
for (const item of removals) {
|
|
1191
|
-
if (!isRecord3(item) || typeof item.requestId !== "string" || !requestIds.has(item.requestId))
|
|
1192
|
-
return null;
|
|
1193
|
-
if (typeof item.table !== "string" || !isRecord3(item.key))
|
|
1194
|
-
return null;
|
|
1195
|
-
result.removals.push({ requestId: item.requestId, table: item.table, key: item.key });
|
|
1196
|
-
}
|
|
1197
|
-
for (const item of rejected) {
|
|
1198
|
-
if (!isRecord3(item) || typeof item.requestId !== "string" || !requestIds.has(item.requestId))
|
|
1199
|
-
return null;
|
|
1200
|
-
if (typeof item.code !== "string" || typeof item.message !== "string")
|
|
1201
|
-
return null;
|
|
1202
|
-
result.rejected.push({ requestId: item.requestId, code: item.code, message: item.message });
|
|
1203
|
-
}
|
|
1204
|
-
return result;
|
|
1205
|
-
}
|
|
1206
|
-
function parseResult(value, job) {
|
|
1207
|
-
if (!isRecord3(value) || value.kind !== job.kind)
|
|
1208
|
-
return null;
|
|
1209
|
-
if (job.kind === "push")
|
|
1210
|
-
return parsePushResult(value, new Set(job.requests.map((request) => request.requestId)));
|
|
1211
|
-
const logs = parseLogs(value.logs);
|
|
1212
|
-
if (!logs)
|
|
1213
|
-
return null;
|
|
1214
|
-
if (job.kind === "discover") {
|
|
1215
|
-
const summary = parseSummary(value.summary);
|
|
1216
|
-
const discover = parseDiscover(value.discover);
|
|
1217
|
-
return summary && discover ? { kind: "discover", summary, discover, logs } : null;
|
|
1218
|
-
}
|
|
1219
|
-
if (job.kind === "check")
|
|
1220
|
-
return value.ok === true ? { kind: "check", ok: true, logs } : null;
|
|
1221
|
-
const emittedBatches = value.emittedBatches;
|
|
1222
|
-
if (typeof emittedBatches !== "number" || !Number.isSafeInteger(emittedBatches) || emittedBatches < 0)
|
|
1223
|
-
return null;
|
|
1224
|
-
if (value.stopped !== undefined && typeof value.stopped !== "boolean")
|
|
1225
|
-
return null;
|
|
1226
|
-
return { kind: "pull", emittedBatches, ...value.stopped === true ? { stopped: true } : {}, logs };
|
|
1227
|
-
}
|
|
1228
|
-
function parseDeclaration(value) {
|
|
1229
|
-
if (!isRecord3(value))
|
|
1230
|
-
return null;
|
|
1231
|
-
const summary = parseSummary(value.summary);
|
|
1232
|
-
const discover = parseDiscover(value.discover);
|
|
1233
|
-
return summary && discover ? { summary, discover } : null;
|
|
1234
|
-
}
|
|
1235
|
-
function parseBatch(value, tables) {
|
|
1236
|
-
if (!isRecord3(value) || !Array.isArray(value.records) || !Array.isArray(value.removals))
|
|
1237
|
-
return null;
|
|
1238
|
-
const logs = parseLogs(value.logs);
|
|
1239
|
-
if (!logs)
|
|
1240
|
-
return null;
|
|
1241
|
-
const batch = { records: [], removals: [], logs };
|
|
1242
|
-
for (const item of value.records) {
|
|
1243
|
-
if (!isRecord3(item) || typeof item.table !== "string" || !tables.has(item.table) || !isRecord3(item.row))
|
|
1244
|
-
return null;
|
|
1245
|
-
batch.records.push({ table: item.table, row: item.row });
|
|
1246
|
-
}
|
|
1247
|
-
for (const item of value.removals) {
|
|
1248
|
-
if (!isRecord3(item) || typeof item.table !== "string" || !tables.has(item.table) || !isRecord3(item.key))
|
|
1249
|
-
return null;
|
|
1250
|
-
batch.removals.push({ table: item.table, key: item.key });
|
|
1251
|
-
}
|
|
1252
|
-
if (value.checkpoint !== undefined) {
|
|
1253
|
-
if (!isRecord3(value.checkpoint) || !Object.hasOwn(value.checkpoint, "state"))
|
|
1254
|
-
return null;
|
|
1255
|
-
batch.checkpoint = { state: value.checkpoint.state };
|
|
1256
|
-
}
|
|
1257
|
-
if (value.dropped !== undefined) {
|
|
1258
|
-
if (typeof value.dropped !== "number" || !Number.isSafeInteger(value.dropped) || value.dropped < 0)
|
|
1259
|
-
return null;
|
|
1260
|
-
batch.dropped = value.dropped;
|
|
1261
|
-
}
|
|
1262
|
-
return batch;
|
|
1263
|
-
}
|
|
1264
|
-
function parseHttpRequest2(value) {
|
|
1265
|
-
if (!isRecord3(value))
|
|
1266
|
-
return null;
|
|
1267
|
-
const { method, url, headers, query, body, bodyEncoding, timeoutMs } = value;
|
|
1268
|
-
if (typeof method !== "string" || !HTTP_METHODS.has(method) || typeof url !== "string" || !isStringRecord(headers))
|
|
1269
|
-
return null;
|
|
1270
|
-
if (!isRecord3(query) || !Object.values(query).every((item) => typeof item === "string" || isStringArray(item)))
|
|
1271
|
-
return null;
|
|
1272
|
-
if (body !== null && typeof body !== "string")
|
|
1273
|
-
return null;
|
|
1274
|
-
if (typeof bodyEncoding !== "string" || !BODY_ENCODINGS.has(bodyEncoding))
|
|
1275
|
-
return null;
|
|
1276
|
-
if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0))
|
|
1277
|
-
return null;
|
|
1278
|
-
const parsedQuery = {};
|
|
1279
|
-
for (const [key, item] of Object.entries(query))
|
|
1280
|
-
parsedQuery[key] = Array.isArray(item) ? [...item] : item;
|
|
1281
|
-
return {
|
|
1282
|
-
method,
|
|
1283
|
-
url,
|
|
1284
|
-
headers: { ...headers },
|
|
1285
|
-
query: parsedQuery,
|
|
1286
|
-
body,
|
|
1287
|
-
bodyEncoding,
|
|
1288
|
-
...timeoutMs !== undefined ? { timeoutMs } : {}
|
|
1289
|
-
};
|
|
1290
|
-
}
|
|
1291
|
-
function parseCryptoRequest(value) {
|
|
1292
|
-
if (!isRecord3(value) || typeof value.data !== "string" || typeof value.algorithm !== "string")
|
|
1293
|
-
return null;
|
|
1294
|
-
if (typeof value.encoding !== "string" || !DIGEST_ENCODINGS.has(value.encoding))
|
|
1295
|
-
return null;
|
|
1296
|
-
const { data, algorithm } = value;
|
|
1297
|
-
const encoding = value.encoding;
|
|
1298
|
-
if (value.op === "hash") {
|
|
1299
|
-
return HASH_ALGORITHMS.has(algorithm) ? { op: "hash", algorithm, data, encoding } : null;
|
|
1300
|
-
}
|
|
1301
|
-
if (typeof value.key !== "string")
|
|
1302
|
-
return null;
|
|
1303
|
-
if (value.op === "hmac") {
|
|
1304
|
-
return HMAC_ALGORITHMS.has(algorithm) ? { op: "hmac", algorithm, key: value.key, data, encoding } : null;
|
|
1305
|
-
}
|
|
1306
|
-
if (value.op === "sign")
|
|
1307
|
-
return algorithm === "rsa-sha256" ? { op: "sign", algorithm: "rsa-sha256", key: value.key, data, encoding } : null;
|
|
1308
|
-
return null;
|
|
1309
|
-
}
|
|
1310
|
-
function parsePayload(method, payload, job, options) {
|
|
1311
|
-
switch (method) {
|
|
1312
|
-
case "definition":
|
|
1313
|
-
return job.kind === "push" ? null : parseSummary(payload);
|
|
1314
|
-
case "declaration":
|
|
1315
|
-
if (options.declared === true)
|
|
1316
|
-
return null;
|
|
1317
|
-
return job.kind === "pull" && job.discoverFirst === true ? parseDeclaration(payload) : null;
|
|
1318
|
-
case "http":
|
|
1319
|
-
return parseHttpRequest2(payload);
|
|
1320
|
-
case "db.query":
|
|
1321
|
-
return isRecord3(payload) && typeof payload.sql === "string" && Array.isArray(payload.params) ? { sql: payload.sql, params: [...payload.params] } : null;
|
|
1322
|
-
case "db.next":
|
|
1323
|
-
case "db.close":
|
|
1324
|
-
return isRecord3(payload) && typeof payload.cursorId === "string" ? { cursorId: payload.cursorId } : null;
|
|
1325
|
-
case "crypto":
|
|
1326
|
-
return parseCryptoRequest(payload);
|
|
1327
|
-
case "cache.get":
|
|
1328
|
-
return isRecord3(payload) && typeof payload.key === "string" ? { key: payload.key } : null;
|
|
1329
|
-
case "cache.set": {
|
|
1330
|
-
if (!isRecord3(payload) || typeof payload.key !== "string" || typeof payload.value !== "string")
|
|
1331
|
-
return null;
|
|
1332
|
-
const ttl = payload.ttlSeconds;
|
|
1333
|
-
return typeof ttl === "number" && Number.isSafeInteger(ttl) && ttl > 0 ? { key: payload.key, value: payload.value, ttlSeconds: ttl } : null;
|
|
1334
|
-
}
|
|
1335
|
-
case "secret.reveal":
|
|
1336
|
-
return isRecord3(payload) && typeof payload.field === "string" && job.secretFields.includes(payload.field) ? { field: payload.field } : null;
|
|
1337
|
-
case "emit":
|
|
1338
|
-
if (job.kind !== "pull")
|
|
1339
|
-
return null;
|
|
1340
|
-
return parseBatch(payload, new Set(job.discoverFirst === true ? options.tables ?? [] : job.tables));
|
|
1341
|
-
}
|
|
1342
|
-
}
|
|
1343
|
-
function undeclaredEmitTable(value, job, options = {}) {
|
|
1344
|
-
if (job.kind !== "pull" || !isRecord3(value) || value.type !== "call" || value.method !== "emit" || !isRecord3(value.payload))
|
|
1345
|
-
return null;
|
|
1346
|
-
const allowed = new Set(job.discoverFirst === true ? options.tables ?? [] : job.tables);
|
|
1347
|
-
for (const key of ["records", "removals"]) {
|
|
1348
|
-
const items = value.payload[key];
|
|
1349
|
-
if (!Array.isArray(items))
|
|
1350
|
-
continue;
|
|
1351
|
-
for (const item of items) {
|
|
1352
|
-
if (isRecord3(item) && typeof item.table === "string" && !allowed.has(item.table))
|
|
1353
|
-
return item.table;
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
return null;
|
|
1357
|
-
}
|
|
1358
|
-
function parseChildMessage(value, job, options = {}) {
|
|
1359
|
-
if (!isRecord3(value))
|
|
1360
|
-
return null;
|
|
1361
|
-
switch (value.type) {
|
|
1362
|
-
case "ready":
|
|
1363
|
-
return { type: "ready" };
|
|
1364
|
-
case "call": {
|
|
1365
|
-
if (!isId(value.id) || typeof value.method !== "string" || !HOST_METHOD_SET.has(value.method))
|
|
1366
|
-
return null;
|
|
1367
|
-
const method = value.method;
|
|
1368
|
-
const payload = parsePayload(method, value.payload, job, options);
|
|
1369
|
-
return payload === null ? null : { type: "call", id: value.id, method, payload };
|
|
1370
|
-
}
|
|
1371
|
-
case "result": {
|
|
1372
|
-
const result = parseResult(value.result, job);
|
|
1373
|
-
return result ? { type: "result", result } : null;
|
|
1374
|
-
}
|
|
1375
|
-
case "failure": {
|
|
1376
|
-
if (typeof value.code !== "string" || !FAILURE_CODE_SET.has(value.code) || typeof value.message !== "string")
|
|
1377
|
-
return null;
|
|
1378
|
-
if (value.stack !== undefined && typeof value.stack !== "string")
|
|
1379
|
-
return null;
|
|
1380
|
-
const logs = parseLogs(value.logs);
|
|
1381
|
-
if (!logs)
|
|
1382
|
-
return null;
|
|
1383
|
-
return {
|
|
1384
|
-
type: "failure",
|
|
1385
|
-
code: value.code,
|
|
1386
|
-
message: value.message,
|
|
1387
|
-
...value.stack !== undefined ? { stack: value.stack } : {},
|
|
1388
|
-
logs
|
|
1389
|
-
};
|
|
1390
|
-
}
|
|
1391
|
-
default:
|
|
1392
|
-
return null;
|
|
1393
|
-
}
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
// src/runtime/session.ts
|
|
1397
|
-
function isRecord4(value) {
|
|
1398
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1399
|
-
}
|
|
1400
|
-
function guestErrorMessage(error) {
|
|
1401
|
-
try {
|
|
1402
|
-
if (error !== null && typeof error === "object") {
|
|
1403
|
-
const message = error.message;
|
|
1404
|
-
if (typeof message === "string")
|
|
1405
|
-
return message;
|
|
1406
|
-
}
|
|
1407
|
-
} catch {}
|
|
1408
|
-
return "unknown error";
|
|
1409
|
-
}
|
|
1410
|
-
function createInProcessGuestSession(send, definition) {
|
|
1411
|
-
return createSession(send, (guestSource) => {
|
|
1412
|
-
let exported;
|
|
1413
|
-
try {
|
|
1414
|
-
const module2 = { exports: {} };
|
|
1415
|
-
const noRequire = (specifier) => {
|
|
1416
|
-
throw new Error(`guest runtime cannot require ${specifier}`);
|
|
1417
|
-
};
|
|
1418
|
-
const load = new Function("module", "exports", "require", guestSource);
|
|
1419
|
-
load(module2, module2.exports, noRequire);
|
|
1420
|
-
exported = module2.exports;
|
|
1421
|
-
} catch (error) {
|
|
1422
|
-
return { ok: false, code: "CONNECTOR_LOAD_FAILED", message: `connector runtime failed to start: ${guestErrorMessage(error)}` };
|
|
1423
|
-
}
|
|
1424
|
-
if (typeof exported.bootLocalGuest !== "function") {
|
|
1425
|
-
return { ok: false, code: "CONNECTOR_LOAD_FAILED", message: "connector runtime failed to start: guest runtime does not export bootLocalGuest" };
|
|
1426
|
-
}
|
|
1427
|
-
const guest = exported.bootLocalGuest();
|
|
1428
|
-
const factory = (module2) => {
|
|
1429
|
-
module2.exports = definition;
|
|
1430
|
-
};
|
|
1431
|
-
return { ok: true, guest, factory };
|
|
1432
|
-
});
|
|
1433
|
-
}
|
|
1434
|
-
function createSession(send, boot) {
|
|
1435
|
-
let guest = null;
|
|
1436
|
-
let started = false;
|
|
1437
|
-
let done = false;
|
|
1438
|
-
let timer;
|
|
1439
|
-
let timerAt = Number.POSITIVE_INFINITY;
|
|
1440
|
-
const stop = () => {
|
|
1441
|
-
done = true;
|
|
1442
|
-
if (timer !== undefined)
|
|
1443
|
-
clearTimeout(timer);
|
|
1444
|
-
timer = undefined;
|
|
1445
|
-
};
|
|
1446
|
-
const fail = (code, message) => {
|
|
1447
|
-
if (done)
|
|
1448
|
-
return;
|
|
1449
|
-
stop();
|
|
1450
|
-
send(JSON.stringify({ type: "failure", code, message, logs: [] }));
|
|
1451
|
-
};
|
|
1452
|
-
const schedule = (delay) => {
|
|
1453
|
-
if (done)
|
|
1454
|
-
return;
|
|
1455
|
-
const at = Date.now() + delay;
|
|
1456
|
-
if (timer !== undefined && timerAt <= at)
|
|
1457
|
-
return;
|
|
1458
|
-
if (timer !== undefined)
|
|
1459
|
-
clearTimeout(timer);
|
|
1460
|
-
timerAt = at;
|
|
1461
|
-
timer = setTimeout(pump, delay);
|
|
1462
|
-
};
|
|
1463
|
-
function pump() {
|
|
1464
|
-
timer = undefined;
|
|
1465
|
-
timerAt = Number.POSITIVE_INFINITY;
|
|
1466
|
-
if (done || !guest)
|
|
1467
|
-
return;
|
|
1468
|
-
let text;
|
|
1469
|
-
try {
|
|
1470
|
-
text = guest.poll();
|
|
1471
|
-
} catch (error) {
|
|
1472
|
-
fail("FATAL_ERROR", `connector runtime failed: ${guestErrorMessage(error)}`);
|
|
1473
|
-
return;
|
|
1474
|
-
}
|
|
1475
|
-
let parsed;
|
|
1476
|
-
try {
|
|
1477
|
-
parsed = typeof text === "string" ? JSON.parse(text) : null;
|
|
1478
|
-
} catch {
|
|
1479
|
-
parsed = null;
|
|
1480
|
-
}
|
|
1481
|
-
if (!isRecord4(parsed) || !Array.isArray(parsed.out)) {
|
|
1482
|
-
fail("FATAL_ERROR", "connector runtime returned a malformed poll result");
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
for (const message of parsed.out) {
|
|
1486
|
-
if (!isRecord4(message))
|
|
1487
|
-
continue;
|
|
1488
|
-
send(JSON.stringify(message));
|
|
1489
|
-
if (message.type === "result" || message.type === "failure") {
|
|
1490
|
-
stop();
|
|
1491
|
-
return;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
if (parsed.out.length > 0 || parsed.fired === true)
|
|
1495
|
-
schedule(0);
|
|
1496
|
-
else if (typeof parsed.wake === "number" && parsed.wake >= 0)
|
|
1497
|
-
schedule(parsed.wake);
|
|
1498
|
-
}
|
|
1499
|
-
const execute = (guestSource, code, payload) => {
|
|
1500
|
-
const booted = boot(guestSource, code);
|
|
1501
|
-
if (!booted.ok) {
|
|
1502
|
-
fail(booted.code, booted.message);
|
|
1503
|
-
return;
|
|
1504
|
-
}
|
|
1505
|
-
guest = booted.guest;
|
|
1506
|
-
try {
|
|
1507
|
-
guest.start(payload, booted.factory);
|
|
1508
|
-
} catch (error) {
|
|
1509
|
-
fail("FATAL_ERROR", `connector runtime failed: ${guestErrorMessage(error)}`);
|
|
1510
|
-
return;
|
|
1511
|
-
}
|
|
1512
|
-
schedule(0);
|
|
1513
|
-
};
|
|
1514
|
-
return {
|
|
1515
|
-
handle(json) {
|
|
1516
|
-
if (done || typeof json !== "string")
|
|
1517
|
-
return;
|
|
1518
|
-
let raw;
|
|
1519
|
-
try {
|
|
1520
|
-
raw = JSON.parse(json);
|
|
1521
|
-
} catch {
|
|
1522
|
-
return;
|
|
1523
|
-
}
|
|
1524
|
-
const message = parseParentMessage(raw);
|
|
1525
|
-
if (!message)
|
|
1526
|
-
return;
|
|
1527
|
-
if (message.type === "execute") {
|
|
1528
|
-
if (started)
|
|
1529
|
-
return;
|
|
1530
|
-
started = true;
|
|
1531
|
-
execute(message.guest, message.code, JSON.stringify({ expect: message.expect, job: message.job }));
|
|
1532
|
-
return;
|
|
1533
|
-
}
|
|
1534
|
-
if (!guest)
|
|
1535
|
-
return;
|
|
1536
|
-
try {
|
|
1537
|
-
guest.handle(JSON.stringify(message));
|
|
1538
|
-
} catch (error) {
|
|
1539
|
-
fail("FATAL_ERROR", `connector runtime failed: ${guestErrorMessage(error)}`);
|
|
1540
|
-
return;
|
|
1541
|
-
}
|
|
1542
|
-
schedule(0);
|
|
1543
|
-
}
|
|
1544
|
-
};
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
// src/validate.ts
|
|
1548
|
-
var RESERVED_PUSH_NAMES = Object.freeze(["dev"]);
|
|
1549
|
-
var SCALAR_KINDS = new Set(["string", "integer", "decimal", "boolean", "date", "instant", "json"]);
|
|
1550
|
-
var RETENTION_KINDS = new Set(["instant", "date"]);
|
|
1551
|
-
var SYNC_MODES2 = new Set(["full", "incremental"]);
|
|
1552
|
-
var SNAPSHOTS2 = new Set(["complete", "partial"]);
|
|
1553
|
-
var HOST_LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
1554
|
-
var HOST_PATTERN = new RegExp(`^(?:\\*\\.)?${HOST_LABEL}(?:\\.${HOST_LABEL})*$`, "i");
|
|
1555
|
-
var PRESENTATION_LIMITS = Object.freeze({
|
|
1556
|
-
title: 40,
|
|
1557
|
-
description: 120,
|
|
1558
|
-
readmeBytes: 65536,
|
|
1559
|
-
iconBytes: 262144,
|
|
1560
|
-
iconMinPx: 128,
|
|
1561
|
-
fieldTitle: 40,
|
|
1562
|
-
fieldDescription: 200,
|
|
1563
|
-
placeholder: 100,
|
|
1564
|
-
tableTitle: 40,
|
|
1565
|
-
tableDescription: 200
|
|
1566
|
-
});
|
|
1567
|
-
function isRecord5(value) {
|
|
1568
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1569
|
-
}
|
|
1570
|
-
function pushNames(push) {
|
|
1571
|
-
return isRecord5(push) ? Object.keys(push) : [];
|
|
1572
|
-
}
|
|
1573
|
-
function definitionStreamNames(definition) {
|
|
1574
|
-
if (!isRecord5(definition) || !isRecord5(definition.streams))
|
|
1575
|
-
return [];
|
|
1576
|
-
return Object.keys(definition.streams);
|
|
1577
|
-
}
|
|
1578
|
-
function topLevelSecrets(config) {
|
|
1579
|
-
if (!isRecord5(config) || !isRecord5(config.shape))
|
|
1580
|
-
return [];
|
|
1581
|
-
const secrets = [];
|
|
1582
|
-
for (const [field, type] of Object.entries(config.shape)) {
|
|
1583
|
-
if (isRecord5(type) && type.kind === "secret")
|
|
1584
|
-
secrets.push({ field, exposeToScript: type.exposeToScript === true });
|
|
1585
|
-
}
|
|
1586
|
-
return secrets;
|
|
1587
|
-
}
|
|
1588
|
-
function summarizeDefinition(definition) {
|
|
1589
|
-
const secrets = topLevelSecrets(definition.config);
|
|
1590
|
-
const rateLimit = definition.rateLimit;
|
|
1591
|
-
return {
|
|
1592
|
-
name: definition.name,
|
|
1593
|
-
version: definition.version,
|
|
1594
|
-
hosts: Array.isArray(definition.hosts) ? definition.hosts.filter((host) => typeof host === "string") : [],
|
|
1595
|
-
db: definition.db?.engine === "postgresql" ? { engine: "postgresql" } : null,
|
|
1596
|
-
rateLimit: rateLimit ? { perSecond: rateLimit.perSecond, concurrency: rateLimit.concurrency } : null,
|
|
1597
|
-
pushes: pushNames(definition.push),
|
|
1598
|
-
streams: definitionStreamNames(definition),
|
|
1599
|
-
secretFields: secrets.map((secret) => secret.field),
|
|
1600
|
-
exposeToScript: secrets.filter((secret) => secret.exposeToScript).map((secret) => secret.field),
|
|
1601
|
-
...isRecord5(definition.config) ? { config: JSON.parse(JSON.stringify(definition.config)) } : {}
|
|
1602
|
-
};
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
// src/testing/engine.ts
|
|
1606
|
-
function isRecord6(value) {
|
|
1607
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1608
|
-
}
|
|
1609
|
-
function errorMessage2(error) {
|
|
1610
|
-
return error instanceof Error ? error.message : String(error);
|
|
1611
|
-
}
|
|
1612
|
-
function emptyResult() {
|
|
1613
|
-
return { records: [], removals: [], checkpoints: [], logs: [], rejected: [], declaration: null, stopped: false, error: null };
|
|
1614
|
-
}
|
|
1615
|
-
function hostCallError(error) {
|
|
1616
|
-
const code = isRecord6(error) && typeof error.code === "string" ? error.code : "HOST_CALL_FAILED";
|
|
1617
|
-
const result = { code: code === "HOST_DENIED" ? "EGRESS_HOST_DENIED" : code, message: errorMessage2(error) };
|
|
1618
|
-
if (isRecord6(error) && typeof error.status === "number" && Number.isInteger(error.status))
|
|
1619
|
-
result.status = error.status;
|
|
1620
|
-
if (isRecord6(error) && typeof error.retryAfterMs === "number" && error.retryAfterMs >= 0)
|
|
1621
|
-
result.retryAfterMs = error.retryAfterMs;
|
|
1622
|
-
return result;
|
|
1623
|
-
}
|
|
1624
|
-
function decode(raw) {
|
|
1625
|
-
if (typeof raw !== "string")
|
|
1626
|
-
return raw;
|
|
1627
|
-
try {
|
|
1628
|
-
return JSON.parse(raw);
|
|
1629
|
-
} catch {
|
|
1630
|
-
return null;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
function drive(transportFactory, input) {
|
|
1634
|
-
const { execute, host } = input;
|
|
1635
|
-
const job = execute.job;
|
|
1636
|
-
const redact = (text) => host.redact ? host.redact(text) : text;
|
|
1637
|
-
const result = emptyResult();
|
|
1638
|
-
const limit = input.limit !== undefined && Number.isFinite(input.limit) && input.limit >= 0 ? Math.floor(input.limit) : null;
|
|
1639
|
-
let declaredTables = [];
|
|
1640
|
-
let declared = false;
|
|
1641
|
-
let seq = 0;
|
|
1642
|
-
let finished = false;
|
|
1643
|
-
const addLogs = (logs) => {
|
|
1644
|
-
for (const log of logs)
|
|
1645
|
-
result.logs.push({ at: log.at, level: log.level, message: redact(log.message) });
|
|
1646
|
-
};
|
|
1647
|
-
return new Promise((resolve) => {
|
|
1648
|
-
let timer;
|
|
1649
|
-
let transport = null;
|
|
1650
|
-
const finish = (error) => {
|
|
1651
|
-
if (finished)
|
|
1652
|
-
return;
|
|
1653
|
-
finished = true;
|
|
1654
|
-
if (timer !== undefined)
|
|
1655
|
-
clearTimeout(timer);
|
|
1656
|
-
if (error)
|
|
1657
|
-
result.error = { code: error.code, message: redact(error.message), stack: error.stack === null ? null : redact(error.stack) };
|
|
1658
|
-
const stopping = transport ? transport.stop() : Promise.resolve();
|
|
1659
|
-
stopping.then(() => resolve(result), () => resolve(result));
|
|
1660
|
-
};
|
|
1661
|
-
const send = (message) => {
|
|
1662
|
-
if (finished || !transport)
|
|
1663
|
-
return;
|
|
1664
|
-
transport.send(message);
|
|
1665
|
-
};
|
|
1666
|
-
const onEmit = (id, payload) => {
|
|
1667
|
-
const batch = payload;
|
|
1668
|
-
seq += 1;
|
|
1669
|
-
addLogs(batch.logs);
|
|
1670
|
-
let records = batch.records;
|
|
1671
|
-
let stop = false;
|
|
1672
|
-
if (limit !== null) {
|
|
1673
|
-
const room = Math.max(0, limit - result.records.length);
|
|
1674
|
-
if (records.length >= room) {
|
|
1675
|
-
stop = true;
|
|
1676
|
-
records = records.slice(0, room);
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
const truncated = records.length < batch.records.length;
|
|
1680
|
-
result.records.push(...records);
|
|
1681
|
-
result.removals.push(...batch.removals);
|
|
1682
|
-
if (batch.checkpoint && !truncated)
|
|
1683
|
-
result.checkpoints.push({ seq, state: batch.checkpoint.state });
|
|
1684
|
-
if (stop)
|
|
1685
|
-
result.stopped = true;
|
|
1686
|
-
send({ type: "reply", id, ok: true, value: { seq, duplicate: false, rejected: 0, ...stop ? { stop: true } : {} } });
|
|
1687
|
-
};
|
|
1688
|
-
const onCall = (message) => {
|
|
1689
|
-
switch (message.method) {
|
|
1690
|
-
case "definition":
|
|
1691
|
-
try {
|
|
1692
|
-
host.onDefinition?.(message.payload);
|
|
1693
|
-
} catch (error) {
|
|
1694
|
-
finish({ code: "RUNNER_CRASHED", message: `the host rejected the connector definition: ${errorMessage2(error)}`, stack: null });
|
|
1695
|
-
return;
|
|
1696
|
-
}
|
|
1697
|
-
send({ type: "reply", id: message.id, ok: true, value: null });
|
|
1698
|
-
return;
|
|
1699
|
-
case "declaration": {
|
|
1700
|
-
const report = message.payload;
|
|
1701
|
-
result.declaration = { summary: report.summary, discover: report.discover };
|
|
1702
|
-
declared = true;
|
|
1703
|
-
const stream = job.kind === "pull" && Object.hasOwn(report.discover.streams, job.stream) ? report.discover.streams[job.stream] : undefined;
|
|
1704
|
-
declaredTables = stream ? Object.keys(stream.tables) : [];
|
|
1705
|
-
send({ type: "reply", id: message.id, ok: true, value: null });
|
|
1706
|
-
return;
|
|
1707
|
-
}
|
|
1708
|
-
case "emit":
|
|
1709
|
-
onEmit(message.id, message.payload);
|
|
1710
|
-
return;
|
|
1711
|
-
default:
|
|
1712
|
-
host.call(message.method, message.payload).then((value) => send({ type: "reply", id: message.id, ok: true, value: value ?? null }), (error) => {
|
|
1713
|
-
if (error instanceof LocalRunError && error.code === "DB_DRIVER_MISSING") {
|
|
1714
|
-
finish({ code: error.code, message: error.message, stack: null });
|
|
1715
|
-
return;
|
|
1716
|
-
}
|
|
1717
|
-
send({ type: "reply", id: message.id, ok: false, error: hostCallError(error) });
|
|
1718
|
-
});
|
|
1719
|
-
}
|
|
1720
|
-
};
|
|
1721
|
-
const onResult = (outcome) => {
|
|
1722
|
-
switch (outcome.kind) {
|
|
1723
|
-
case "push":
|
|
1724
|
-
for (const record of outcome.records)
|
|
1725
|
-
result.records.push({ table: record.table, row: record.row });
|
|
1726
|
-
for (const removal of outcome.removals)
|
|
1727
|
-
result.removals.push({ table: removal.table, key: removal.key });
|
|
1728
|
-
result.rejected.push(...outcome.rejected.map((item) => ({ requestId: item.requestId, code: item.code, message: redact(item.message) })));
|
|
1729
|
-
break;
|
|
1730
|
-
case "discover":
|
|
1731
|
-
result.declaration = { summary: outcome.summary, discover: outcome.discover };
|
|
1732
|
-
break;
|
|
1733
|
-
case "check":
|
|
1734
|
-
break;
|
|
1735
|
-
case "pull":
|
|
1736
|
-
if (outcome.stopped === true)
|
|
1737
|
-
result.stopped = true;
|
|
1738
|
-
break;
|
|
1739
|
-
}
|
|
1740
|
-
addLogs(outcome.logs);
|
|
1741
|
-
finish(null);
|
|
1742
|
-
};
|
|
1743
|
-
const onMessage = (raw) => {
|
|
1744
|
-
if (finished)
|
|
1745
|
-
return;
|
|
1746
|
-
const decoded = decode(raw);
|
|
1747
|
-
const message = parseChildMessage(decoded, job, { tables: declaredTables, declared });
|
|
1748
|
-
if (!message) {
|
|
1749
|
-
const table = undeclaredEmitTable(decoded, job, { tables: declaredTables });
|
|
1750
|
-
const call = decoded;
|
|
1751
|
-
if (table !== null) {
|
|
1752
|
-
finish({ code: "SCHEMA_ERROR", message: `connector emitted records for undeclared table ${table}`, stack: null });
|
|
1753
|
-
} else if (declared && typeof call === "object" && call !== null && call.type === "call" && call.method === "declaration") {
|
|
1754
|
-
finish({ code: "RUNNER_CRASHED", message: "connector sandbox reported the declaration more than once (protocol error)", stack: null });
|
|
1755
|
-
} else {
|
|
1756
|
-
finish({ code: "RUNNER_CRASHED", message: "connector sandbox sent a malformed message", stack: null });
|
|
1757
|
-
}
|
|
1758
|
-
return;
|
|
1759
|
-
}
|
|
1760
|
-
switch (message.type) {
|
|
1761
|
-
case "ready":
|
|
1762
|
-
send(execute);
|
|
1763
|
-
return;
|
|
1764
|
-
case "call":
|
|
1765
|
-
onCall(message);
|
|
1766
|
-
return;
|
|
1767
|
-
case "result":
|
|
1768
|
-
onResult(message.result);
|
|
1769
|
-
return;
|
|
1770
|
-
case "failure":
|
|
1771
|
-
addLogs(message.logs);
|
|
1772
|
-
finish({ code: message.code, message: message.message, stack: message.stack ?? null });
|
|
1773
|
-
return;
|
|
1774
|
-
}
|
|
1775
|
-
};
|
|
1776
|
-
const onExit = (description) => {
|
|
1777
|
-
finish({ code: "RUNNER_CRASHED", message: `connector sandbox exited without a result (${description})`, stack: null });
|
|
1778
|
-
};
|
|
1779
|
-
try {
|
|
1780
|
-
transport = transportFactory(onMessage, onExit);
|
|
1781
|
-
} catch (error) {
|
|
1782
|
-
finish({ code: "RUNNER_CRASHED", message: `connector sandbox failed to start: ${errorMessage2(error)}`, stack: null });
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
if (input.timeoutMs !== undefined && Number.isFinite(input.timeoutMs)) {
|
|
1786
|
-
timer = setTimeout(() => {
|
|
1787
|
-
finish({ code: "TIMEOUT", message: `connector execution exceeded ${input.timeoutMs} ms`, stack: null });
|
|
1788
|
-
}, Math.max(0, input.timeoutMs));
|
|
1789
|
-
}
|
|
1790
|
-
if (!input.waitForReady)
|
|
1791
|
-
send(execute);
|
|
1792
|
-
});
|
|
1793
|
-
}
|
|
1794
|
-
function createInProcessExecutor(definition) {
|
|
1795
|
-
return {
|
|
1796
|
-
async run(job, host, options = {}) {
|
|
1797
|
-
const guest = await loadGuestRuntime();
|
|
1798
|
-
const named = isRecord6(definition) ? definition : {};
|
|
1799
|
-
const expect = { name: String(named.name ?? ""), version: String(named.version ?? "") };
|
|
1800
|
-
const execute = { type: "execute", guest, code: "", expect, job };
|
|
1801
|
-
return drive((onMessage) => {
|
|
1802
|
-
let active = true;
|
|
1803
|
-
const session = createInProcessGuestSession((json) => {
|
|
1804
|
-
if (active)
|
|
1805
|
-
queueMicrotask(() => onMessage(json));
|
|
1806
|
-
}, definition);
|
|
1807
|
-
return {
|
|
1808
|
-
send(message) {
|
|
1809
|
-
if (active)
|
|
1810
|
-
session.handle(JSON.stringify(message));
|
|
1811
|
-
},
|
|
1812
|
-
async stop() {
|
|
1813
|
-
if (!active)
|
|
1814
|
-
return;
|
|
1815
|
-
session.handle(JSON.stringify({ type: "cancel" }));
|
|
1816
|
-
active = false;
|
|
1817
|
-
}
|
|
1818
|
-
};
|
|
1819
|
-
}, { execute, host, limit: options.limit, timeoutMs: options.timeoutMs, waitForReady: false });
|
|
1820
|
-
}
|
|
1821
|
-
};
|
|
1822
|
-
}
|
|
1823
|
-
var VM_CHILD_ENTRY = import_node_path3.default.join(PACKAGE_ROOT, "testing", IS_SOURCE ? "child.ts" : "child.cjs");
|
|
1824
|
-
var KILL_WAIT_MS = 5000;
|
|
1825
|
-
function forkTransport(entry) {
|
|
1826
|
-
return (onMessage, onExit) => {
|
|
1827
|
-
const child = import_node_child_process.fork(entry, [], {
|
|
1828
|
-
env: {},
|
|
1829
|
-
execArgv: [],
|
|
1830
|
-
stdio: ["ignore", "pipe", "inherit", "ipc"],
|
|
1831
|
-
serialization: "json"
|
|
1832
|
-
});
|
|
1833
|
-
child.stdout?.on("data", (chunk) => process.stderr.write(chunk));
|
|
1834
|
-
let exited = false;
|
|
1835
|
-
const exitedPromise = new Promise((resolve) => {
|
|
1836
|
-
child.once("exit", (code, signal) => {
|
|
1837
|
-
exited = true;
|
|
1838
|
-
resolve();
|
|
1839
|
-
onExit(signal ? `signal ${signal}` : `exit code ${String(code)}`);
|
|
1840
|
-
});
|
|
1841
|
-
});
|
|
1842
|
-
child.on("message", (raw) => onMessage(raw));
|
|
1843
|
-
child.on("error", (error) => onExit(`spawn error: ${error.message}`));
|
|
1844
|
-
return {
|
|
1845
|
-
send(message) {
|
|
1846
|
-
if (exited || !child.connected)
|
|
1847
|
-
return;
|
|
1848
|
-
try {
|
|
1849
|
-
child.send(JSON.stringify(message));
|
|
1850
|
-
} catch {}
|
|
1851
|
-
},
|
|
1852
|
-
async stop() {
|
|
1853
|
-
if (!exited)
|
|
1854
|
-
child.kill("SIGKILL");
|
|
1855
|
-
await Promise.race([exitedPromise, new Promise((resolve) => setTimeout(resolve, KILL_WAIT_MS))]);
|
|
1856
|
-
}
|
|
1857
|
-
};
|
|
1858
|
-
};
|
|
1859
|
-
}
|
|
1860
|
-
function createVmExecutor(source, options = {}) {
|
|
1861
|
-
const entry = options.childEntry ?? VM_CHILD_ENTRY;
|
|
1862
|
-
return {
|
|
1863
|
-
async run(job, host, runOptions = {}) {
|
|
1864
|
-
const guest = options.guestRuntime ?? await loadGuestRuntime();
|
|
1865
|
-
const execute = {
|
|
1866
|
-
type: "execute",
|
|
1867
|
-
guest,
|
|
1868
|
-
code: source.code,
|
|
1869
|
-
expect: { name: source.name, version: source.version },
|
|
1870
|
-
job
|
|
1871
|
-
};
|
|
1872
|
-
return drive(forkTransport(entry), { execute, host, limit: runOptions.limit, timeoutMs: runOptions.timeoutMs, waitForReady: true });
|
|
1873
|
-
}
|
|
1874
|
-
};
|
|
1875
|
-
}
|
|
1876
|
-
var INSPECT_ABORT = "inspect-definition: stop after the definition";
|
|
1877
|
-
var MISMATCH_PREFIX = "connector code defines ";
|
|
1878
|
-
async function inspectDefinition(executor, options = {}) {
|
|
1879
|
-
let summary = null;
|
|
1880
|
-
const host = {
|
|
1881
|
-
call: async (method) => {
|
|
1882
|
-
throw new LocalRunError("HOST_DENIED", `${method} is not available while only loading the definition`);
|
|
1883
|
-
},
|
|
1884
|
-
close: async () => {},
|
|
1885
|
-
onDefinition(value) {
|
|
1886
|
-
summary = value;
|
|
1887
|
-
throw new Error(INSPECT_ABORT);
|
|
1888
|
-
}
|
|
1889
|
-
};
|
|
1890
|
-
const result = await executor.run({ kind: "check", config: {}, secretFields: [] }, host, { timeoutMs: options.timeoutMs ?? 30000 });
|
|
1891
|
-
if (isRecord6(summary) && typeof summary.name === "string" && typeof summary.version === "string") {
|
|
1892
|
-
return { ok: true, summary };
|
|
1893
|
-
}
|
|
1894
|
-
const error = result.error ?? { code: "CONNECTOR_LOAD_FAILED", message: "the connector did not report its definition" };
|
|
1895
|
-
return { ok: false, code: error.code, message: error.message, mismatch: error.message.startsWith(MISMATCH_PREFIX) };
|
|
1896
|
-
}
|
|
1897
|
-
function secretFieldsOf(definition) {
|
|
1898
|
-
try {
|
|
1899
|
-
return summarizeDefinition(definition).secretFields;
|
|
1900
|
-
} catch {
|
|
1901
|
-
return [];
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
function runStream(definition, input) {
|
|
1905
|
-
const job = {
|
|
1906
|
-
kind: "pull",
|
|
1907
|
-
config: input.config,
|
|
1908
|
-
secretFields: secretFieldsOf(definition),
|
|
1909
|
-
stream: input.stream,
|
|
1910
|
-
mode: input.mode ?? "full",
|
|
1911
|
-
state: input.state ?? null,
|
|
1912
|
-
streamConfig: input.streamConfig ?? {},
|
|
1913
|
-
tables: [],
|
|
1914
|
-
discoverFirst: true
|
|
1915
|
-
};
|
|
1916
|
-
return createInProcessExecutor(definition).run(job, input.host, input.limit !== undefined ? { limit: input.limit } : {});
|
|
1917
|
-
}
|
|
1918
|
-
function runDiscover(definition, input) {
|
|
1919
|
-
return createInProcessExecutor(definition).run({ kind: "discover", config: input.config, secretFields: secretFieldsOf(definition) }, input.host);
|
|
1920
|
-
}
|
|
1921
|
-
function runCheck(definition, input) {
|
|
1922
|
-
return createInProcessExecutor(definition).run({ kind: "check", config: input.config, secretFields: secretFieldsOf(definition) }, input.host);
|
|
1923
|
-
}
|
|
1924
|
-
function runPush(definition, input) {
|
|
1925
|
-
const job = {
|
|
1926
|
-
kind: "push",
|
|
1927
|
-
config: input.config,
|
|
1928
|
-
secretFields: secretFieldsOf(definition),
|
|
1929
|
-
requests: input.requests.map((request) => ({ ...request, push: input.push }))
|
|
1930
|
-
};
|
|
1931
|
-
return createInProcessExecutor(definition).run(job, input.host);
|
|
1932
|
-
}
|
|
1933
|
-
// src/testing/source-loader.ts
|
|
1934
|
-
var import_promises3 = require("node:fs/promises");
|
|
1935
|
-
var import_node_path4 = __toESM(require("node:path"), 1);
|
|
1936
|
-
|
|
1937
|
-
// src/testing/import-guard.ts
|
|
1938
|
-
var CONNECTOR_SDK_SPECIFIER = "@deepexi/datasense-connector";
|
|
1939
|
-
var RETIRED_SDK_SPECIFIER = ["@mu-ontos", "connector-sdk"].join("/");
|
|
1940
|
-
var MACRO_SPECIFIER = /^macro:/i;
|
|
1941
|
-
var NODE_BUILTINS = new Set([
|
|
1942
|
-
"assert",
|
|
1943
|
-
"async_hooks",
|
|
1944
|
-
"buffer",
|
|
1945
|
-
"child_process",
|
|
1946
|
-
"cluster",
|
|
1947
|
-
"console",
|
|
1948
|
-
"constants",
|
|
1949
|
-
"crypto",
|
|
1950
|
-
"dgram",
|
|
1951
|
-
"diagnostics_channel",
|
|
1952
|
-
"dns",
|
|
1953
|
-
"domain",
|
|
1954
|
-
"events",
|
|
1955
|
-
"fs",
|
|
1956
|
-
"http",
|
|
1957
|
-
"http2",
|
|
1958
|
-
"https",
|
|
1959
|
-
"inspector",
|
|
1960
|
-
"module",
|
|
1961
|
-
"net",
|
|
1962
|
-
"os",
|
|
1963
|
-
"path",
|
|
1964
|
-
"perf_hooks",
|
|
1965
|
-
"process",
|
|
1966
|
-
"punycode",
|
|
1967
|
-
"querystring",
|
|
1968
|
-
"readline",
|
|
1969
|
-
"repl",
|
|
1970
|
-
"stream",
|
|
1971
|
-
"string_decoder",
|
|
1972
|
-
"sys",
|
|
1973
|
-
"timers",
|
|
1974
|
-
"tls",
|
|
1975
|
-
"trace_events",
|
|
1976
|
-
"tty",
|
|
1977
|
-
"url",
|
|
1978
|
-
"util",
|
|
1979
|
-
"v8",
|
|
1980
|
-
"vm",
|
|
1981
|
-
"wasi",
|
|
1982
|
-
"worker_threads",
|
|
1983
|
-
"zlib",
|
|
1984
|
-
"test",
|
|
1985
|
-
"sqlite",
|
|
1986
|
-
"sea"
|
|
1987
|
-
]);
|
|
1988
|
-
function asTypeScript(value) {
|
|
1989
|
-
const api = value !== null && typeof value === "object" && "default" in value && typeof value.default === "object" ? value.default : value;
|
|
1990
|
-
if (!api || typeof api.createSourceFile !== "function" || typeof api.transpileModule !== "function") {
|
|
1991
|
-
throw new TypeError('options.typescript must be the TypeScript compiler API (import ts from "typescript")');
|
|
1992
|
-
}
|
|
1993
|
-
return api;
|
|
1994
|
-
}
|
|
1995
|
-
function scriptKindOf(ts, filePath) {
|
|
1996
|
-
if (filePath.endsWith(".tsx"))
|
|
1997
|
-
return ts.ScriptKind.TSX;
|
|
1998
|
-
if (filePath.endsWith(".jsx"))
|
|
1999
|
-
return ts.ScriptKind.JSX;
|
|
2000
|
-
if (/\.[cm]?js$/.test(filePath))
|
|
2001
|
-
return ts.ScriptKind.JS;
|
|
2002
|
-
return ts.ScriptKind.TS;
|
|
2003
|
-
}
|
|
2004
|
-
function sdkSpecifierProblem(specifier) {
|
|
2005
|
-
if (specifier === RETIRED_SDK_SPECIFIER || specifier.startsWith(`${RETIRED_SDK_SPECIFIER}/`)) {
|
|
2006
|
-
return `import '${specifier}' is not allowed; the SDK package is now '${CONNECTOR_SDK_SPECIFIER}'`;
|
|
2007
|
-
}
|
|
2008
|
-
if (specifier.startsWith(`${CONNECTOR_SDK_SPECIFIER}/`)) {
|
|
2009
|
-
return `import '${specifier}' is not allowed; import '${CONNECTOR_SDK_SPECIFIER}' itself`;
|
|
2010
|
-
}
|
|
2011
|
-
return null;
|
|
2012
|
-
}
|
|
2013
|
-
function literalSpecifier(ts, node) {
|
|
2014
|
-
if (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)))
|
|
2015
|
-
return node.text;
|
|
2016
|
-
return null;
|
|
2017
|
-
}
|
|
2018
|
-
function findForbiddenSourceSyntax(ts, filePath, content) {
|
|
2019
|
-
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKindOf(ts, filePath));
|
|
2020
|
-
const problems = [];
|
|
2021
|
-
const report = (position, message, kind) => {
|
|
2022
|
-
const { line, character } = sourceFile.getLineAndCharacterOfPosition(position);
|
|
2023
|
-
problems.push({ file: filePath, line: line + 1, column: character, message, kind });
|
|
2024
|
-
};
|
|
2025
|
-
const parseDiagnostics = sourceFile.parseDiagnostics ?? [];
|
|
2026
|
-
for (const diagnostic of parseDiagnostics) {
|
|
2027
|
-
report(diagnostic.start ?? 0, `syntax error: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, " ")}`, "syntax");
|
|
2028
|
-
}
|
|
2029
|
-
const checkSpecifier = (node) => {
|
|
2030
|
-
const specifier = literalSpecifier(ts, node);
|
|
2031
|
-
if (!node || specifier === null)
|
|
2032
|
-
return;
|
|
2033
|
-
if (MACRO_SPECIFIER.test(specifier)) {
|
|
2034
|
-
report(node.getStart(sourceFile), `import '${specifier}' is not allowed; connectors cannot use Bun macros`, "import");
|
|
2035
|
-
return;
|
|
2036
|
-
}
|
|
2037
|
-
const sdkProblem = sdkSpecifierProblem(specifier);
|
|
2038
|
-
if (sdkProblem !== null)
|
|
2039
|
-
report(node.getStart(sourceFile), sdkProblem, "import");
|
|
2040
|
-
};
|
|
2041
|
-
const attributesMessage = (keyword) => `import attributes ('${keyword} { … }') are not allowed; connectors cannot use Bun macros or other import attributes`;
|
|
2042
|
-
const visit = (node) => {
|
|
2043
|
-
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
2044
|
-
checkSpecifier(node.moduleSpecifier);
|
|
2045
|
-
if (node.attributes) {
|
|
2046
|
-
report(node.attributes.getStart(sourceFile), attributesMessage(node.attributes.token === ts.SyntaxKind.AssertKeyword ? "assert" : "with"), "import");
|
|
2047
|
-
}
|
|
2048
|
-
} else if (ts.isImportEqualsDeclaration(node)) {
|
|
2049
|
-
if (ts.isExternalModuleReference(node.moduleReference))
|
|
2050
|
-
checkSpecifier(node.moduleReference.expression);
|
|
2051
|
-
} else if (ts.isCallExpression(node)) {
|
|
2052
|
-
const callee = node.expression;
|
|
2053
|
-
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
|
|
2054
|
-
checkSpecifier(node.arguments[0]);
|
|
2055
|
-
const options = node.arguments[1];
|
|
2056
|
-
if (options)
|
|
2057
|
-
report(options.getStart(sourceFile), attributesMessage("with"), "import");
|
|
2058
|
-
} else if (ts.isIdentifier(callee) && callee.text === "require") {
|
|
2059
|
-
checkSpecifier(node.arguments[0]);
|
|
2060
|
-
}
|
|
2061
|
-
}
|
|
2062
|
-
ts.forEachChild(node, visit);
|
|
2063
|
-
};
|
|
2064
|
-
visit(sourceFile);
|
|
2065
|
-
return problems;
|
|
2066
|
-
}
|
|
2067
|
-
function collectRequires(ts, filePath, output) {
|
|
2068
|
-
const sourceFile = ts.createSourceFile(`${filePath}.js`, output, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
|
|
2069
|
-
const found = [];
|
|
2070
|
-
const visit = (node) => {
|
|
2071
|
-
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") {
|
|
2072
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
2073
|
-
found.push({ specifier: literalSpecifier(ts, node.arguments[0]), line: line + 1 });
|
|
2074
|
-
}
|
|
2075
|
-
ts.forEachChild(node, visit);
|
|
2076
|
-
};
|
|
2077
|
-
visit(sourceFile);
|
|
2078
|
-
return found;
|
|
2079
|
-
}
|
|
2080
|
-
function collectSourceSpecifiers(ts, filePath, content) {
|
|
2081
|
-
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKindOf(ts, filePath));
|
|
2082
|
-
const found = [];
|
|
2083
|
-
const add = (node) => {
|
|
2084
|
-
const specifier = literalSpecifier(ts, node);
|
|
2085
|
-
if (!node || specifier === null)
|
|
2086
|
-
return;
|
|
2087
|
-
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
2088
|
-
found.push({ specifier, line: line + 1 });
|
|
2089
|
-
};
|
|
2090
|
-
const visit = (node) => {
|
|
2091
|
-
if (ts.isImportDeclaration(node)) {
|
|
2092
|
-
if (!node.importClause?.isTypeOnly)
|
|
2093
|
-
add(node.moduleSpecifier);
|
|
2094
|
-
} else if (ts.isExportDeclaration(node)) {
|
|
2095
|
-
if (!node.isTypeOnly)
|
|
2096
|
-
add(node.moduleSpecifier);
|
|
2097
|
-
} else if (ts.isImportEqualsDeclaration(node)) {
|
|
2098
|
-
if (!node.isTypeOnly && ts.isExternalModuleReference(node.moduleReference))
|
|
2099
|
-
add(node.moduleReference.expression);
|
|
2100
|
-
} else if (ts.isCallExpression(node)) {
|
|
2101
|
-
const callee = node.expression;
|
|
2102
|
-
if (callee.kind === ts.SyntaxKind.ImportKeyword || ts.isIdentifier(callee) && callee.text === "require")
|
|
2103
|
-
add(node.arguments[0]);
|
|
2104
|
-
}
|
|
2105
|
-
ts.forEachChild(node, visit);
|
|
2106
|
-
};
|
|
2107
|
-
visit(sourceFile);
|
|
2108
|
-
return found;
|
|
2109
|
-
}
|
|
2110
|
-
function packageNameOf(specifier) {
|
|
2111
|
-
const parts = specifier.split("/");
|
|
2112
|
-
if (specifier.startsWith("@"))
|
|
2113
|
-
return parts.slice(0, 2).join("/");
|
|
2114
|
-
return parts[0] ?? specifier;
|
|
2115
|
-
}
|
|
2116
|
-
function normalizePosix(input) {
|
|
2117
|
-
const out = [];
|
|
2118
|
-
for (const segment of input.split("/")) {
|
|
2119
|
-
if (segment === "" || segment === ".")
|
|
2120
|
-
continue;
|
|
2121
|
-
if (segment === "..") {
|
|
2122
|
-
if (out.length > 0 && out[out.length - 1] !== "..")
|
|
2123
|
-
out.pop();
|
|
2124
|
-
else
|
|
2125
|
-
out.push("..");
|
|
2126
|
-
} else {
|
|
2127
|
-
out.push(segment);
|
|
2128
|
-
}
|
|
2129
|
-
}
|
|
2130
|
-
return out.length === 0 ? "." : out.join("/");
|
|
2131
|
-
}
|
|
2132
|
-
function classifySpecifier(specifier, importer) {
|
|
2133
|
-
if (specifier === CONNECTOR_SDK_SPECIFIER)
|
|
2134
|
-
return { kind: "sdk" };
|
|
2135
|
-
const isRelative = specifier === "." || specifier === ".." || specifier.startsWith("./") || specifier.startsWith("../");
|
|
2136
|
-
if (isRelative) {
|
|
2137
|
-
const slash = importer.lastIndexOf("/");
|
|
2138
|
-
const baseDir = slash === -1 ? "" : importer.slice(0, slash);
|
|
2139
|
-
const target = normalizePosix(baseDir ? `${baseDir}/${specifier}` : specifier);
|
|
2140
|
-
if (target === ".." || target.startsWith("../"))
|
|
2141
|
-
return { kind: "escape" };
|
|
2142
|
-
return { kind: "relative", target };
|
|
2143
|
-
}
|
|
2144
|
-
const sdkProblem = sdkSpecifierProblem(specifier);
|
|
2145
|
-
if (sdkProblem !== null)
|
|
2146
|
-
return { kind: "forbidden", message: sdkProblem };
|
|
2147
|
-
if (MACRO_SPECIFIER.test(specifier))
|
|
2148
|
-
return { kind: "forbidden", message: `import '${specifier}' is not allowed; connectors cannot use Bun macros` };
|
|
2149
|
-
if (specifier.startsWith("node:") || specifier.startsWith("bun:") || specifier === "bun" || NODE_BUILTINS.has(packageNameOf(specifier))) {
|
|
2150
|
-
return { kind: "forbidden", message: `import '${specifier}' is not allowed; connectors cannot use Node.js or Bun built-in modules` };
|
|
2151
|
-
}
|
|
2152
|
-
if (specifier.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(specifier)) {
|
|
2153
|
-
return { kind: "forbidden", message: `import '${specifier}' is not allowed; only relative imports and '${CONNECTOR_SDK_SPECIFIER}' are allowed` };
|
|
2154
|
-
}
|
|
2155
|
-
return { kind: "forbidden", message: `import '${specifier}' is not allowed; connectors can only depend on '${CONNECTOR_SDK_SPECIFIER}'` };
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
// src/testing/source-loader.ts
|
|
2159
|
-
var LOCAL_SOURCE_MAX_BYTES = 2 * 1024 * 1024;
|
|
2160
|
-
var LOCAL_SOURCE_MAX_FILES = 200;
|
|
2161
|
-
var EXCLUDED_DIRECTORIES = new Set(["node_modules", ".git", "dist", ".ontos", ".datasense"]);
|
|
2162
|
-
var FIXTURES_DIRECTORY = "fixtures";
|
|
2163
|
-
var DEFAULT_MAIN = "src/index.ts";
|
|
2164
|
-
var NAME_PATTERN = /^[a-z][a-z0-9_]{0,27}$/;
|
|
2165
|
-
var VERSION_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
2166
|
-
var CODE_FILE2 = /\.[cm]?[jt]sx?$/;
|
|
2167
|
-
var RESOLVE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"];
|
|
2168
|
-
function isRecord7(value) {
|
|
2169
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2170
|
-
}
|
|
2171
|
-
async function collectFiles(root) {
|
|
2172
|
-
const files = {};
|
|
2173
|
-
let count = 0;
|
|
2174
|
-
let bytes = 0;
|
|
2175
|
-
const visit = async (absolute, relative) => {
|
|
2176
|
-
const entries = await import_promises3.readdir(absolute, { withFileTypes: true });
|
|
2177
|
-
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2178
|
-
for (const entry of entries) {
|
|
2179
|
-
const childRelative = relative ? `${relative}/${entry.name}` : entry.name;
|
|
2180
|
-
const excluded = EXCLUDED_DIRECTORIES.has(entry.name) || relative === "" && entry.name === FIXTURES_DIRECTORY;
|
|
2181
|
-
if (excluded && (entry.isDirectory() || entry.isSymbolicLink()))
|
|
2182
|
-
continue;
|
|
2183
|
-
if (entry.isSymbolicLink()) {
|
|
2184
|
-
throw new SourceLoadError("SYMLINK", `symbolic links are not supported in connector source: ${childRelative}`, childRelative);
|
|
2185
|
-
}
|
|
2186
|
-
if (entry.isDirectory()) {
|
|
2187
|
-
await visit(import_node_path4.default.join(absolute, entry.name), childRelative);
|
|
2188
|
-
} else if (entry.isFile()) {
|
|
2189
|
-
count += 1;
|
|
2190
|
-
if (count > LOCAL_SOURCE_MAX_FILES) {
|
|
2191
|
-
throw new SourceLoadError("TOO_LARGE", `connector source has more than ${LOCAL_SOURCE_MAX_FILES} files`);
|
|
2192
|
-
}
|
|
2193
|
-
const text = await import_promises3.readFile(import_node_path4.default.join(absolute, entry.name), "utf8");
|
|
2194
|
-
bytes += Buffer.byteLength(text, "utf8");
|
|
2195
|
-
if (bytes > LOCAL_SOURCE_MAX_BYTES) {
|
|
2196
|
-
throw new SourceLoadError("TOO_LARGE", `connector source exceeds ${LOCAL_SOURCE_MAX_BYTES} bytes`);
|
|
2197
|
-
}
|
|
2198
|
-
files[childRelative] = text;
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
};
|
|
2202
|
-
await visit(root, "");
|
|
2203
|
-
return files;
|
|
2204
|
-
}
|
|
2205
|
-
function readManifest(files) {
|
|
2206
|
-
const text = files["package.json"];
|
|
2207
|
-
if (text === undefined)
|
|
2208
|
-
throw new SourceLoadError("MANIFEST_INVALID", "package.json is missing", "package.json");
|
|
2209
|
-
let pkg;
|
|
2210
|
-
try {
|
|
2211
|
-
pkg = JSON.parse(text);
|
|
2212
|
-
} catch {
|
|
2213
|
-
throw new SourceLoadError("MANIFEST_INVALID", "package.json is not valid JSON", "package.json");
|
|
2214
|
-
}
|
|
2215
|
-
if (!isRecord7(pkg))
|
|
2216
|
-
throw new SourceLoadError("MANIFEST_INVALID", "package.json must be a JSON object", "package.json");
|
|
2217
|
-
const invalid = (message) => new SourceLoadError("MANIFEST_INVALID", message, "package.json");
|
|
2218
|
-
if (typeof pkg.name !== "string" || !NAME_PATTERN.test(pkg.name))
|
|
2219
|
-
throw invalid(`name must match ${NAME_PATTERN.source}`);
|
|
2220
|
-
if (typeof pkg.version !== "string" || !VERSION_PATTERN.test(pkg.version))
|
|
2221
|
-
throw invalid("version must be a semver string like 1.0.0");
|
|
2222
|
-
let main = DEFAULT_MAIN;
|
|
2223
|
-
if (pkg.main !== undefined) {
|
|
2224
|
-
if (typeof pkg.main !== "string")
|
|
2225
|
-
throw invalid("main must be a string");
|
|
2226
|
-
main = import_node_path4.default.posix.normalize(pkg.main.startsWith("./") ? pkg.main.slice(2) : pkg.main);
|
|
2227
|
-
if (main.startsWith("/") || main === ".." || main.startsWith("../")) {
|
|
2228
|
-
throw new SourceLoadError("PATH_ESCAPE", `main escapes the connector source root: ${pkg.main}`, "package.json");
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
if (!CODE_FILE2.test(main))
|
|
2232
|
-
throw invalid(`main must be a .ts or .js file: ${main}`);
|
|
2233
|
-
if (files[main] === undefined)
|
|
2234
|
-
throw invalid(`entry file not found: ${main}`);
|
|
2235
|
-
const dependencies = pkg.dependencies;
|
|
2236
|
-
if (!isRecord7(dependencies))
|
|
2237
|
-
throw invalid(`dependencies must list ${CONNECTOR_SDK_SPECIFIER}`);
|
|
2238
|
-
for (const dependency of Object.keys(dependencies)) {
|
|
2239
|
-
if (dependency !== CONNECTOR_SDK_SPECIFIER)
|
|
2240
|
-
throw invalid(`connectors can only depend on ${CONNECTOR_SDK_SPECIFIER}, found ${dependency}`);
|
|
2241
|
-
}
|
|
2242
|
-
const sdkRange = dependencies[CONNECTOR_SDK_SPECIFIER];
|
|
2243
|
-
if (typeof sdkRange !== "string" || sdkRange.length === 0)
|
|
2244
|
-
throw invalid(`dependencies must list ${CONNECTOR_SDK_SPECIFIER}`);
|
|
2245
|
-
return { name: pkg.name, version: pkg.version, main, sdkRange };
|
|
2246
|
-
}
|
|
2247
|
-
function resolveModule(files, target) {
|
|
2248
|
-
const base = target === "." ? "index" : target;
|
|
2249
|
-
const candidates = [base, ...RESOLVE_EXTENSIONS.map((ext) => `${base}${ext}`), ...RESOLVE_EXTENSIONS.map((ext) => `${base}/index${ext}`)];
|
|
2250
|
-
for (const candidate of candidates) {
|
|
2251
|
-
if (Object.hasOwn(files, candidate) && (CODE_FILE2.test(candidate) || candidate.endsWith(".json")))
|
|
2252
|
-
return candidate;
|
|
2253
|
-
}
|
|
2254
|
-
return null;
|
|
2255
|
-
}
|
|
2256
|
-
var SOURCE_MAP_COMMENT = /\n?\/\/# sourceMappingURL=[^\n]*\s*$/;
|
|
2257
|
-
function transpile(ts, id, content) {
|
|
2258
|
-
const output = ts.transpileModule(content, {
|
|
2259
|
-
fileName: id,
|
|
2260
|
-
reportDiagnostics: false,
|
|
2261
|
-
compilerOptions: {
|
|
2262
|
-
module: ts.ModuleKind.CommonJS,
|
|
2263
|
-
target: ts.ScriptTarget.ES2022,
|
|
2264
|
-
esModuleInterop: true,
|
|
2265
|
-
sourceMap: true,
|
|
2266
|
-
inlineSourceMap: false,
|
|
2267
|
-
inlineSources: true,
|
|
2268
|
-
jsx: ts.JsxEmit.React
|
|
2269
|
-
}
|
|
2270
|
-
});
|
|
2271
|
-
const code = output.outputText.replace(SOURCE_MAP_COMMENT, "");
|
|
2272
|
-
let map = { version: 3, sources: [id], names: [], mappings: "" };
|
|
2273
|
-
if (output.sourceMapText) {
|
|
2274
|
-
const parsed = JSON.parse(output.sourceMapText);
|
|
2275
|
-
map = { ...parsed, sources: [id], sourceRoot: "" };
|
|
2276
|
-
delete map.file;
|
|
2277
|
-
}
|
|
2278
|
-
return { code, map };
|
|
2279
|
-
}
|
|
2280
|
-
function loadModule(ts, files, id) {
|
|
2281
|
-
const content = files[id] ?? "";
|
|
2282
|
-
if (id.endsWith(".json")) {
|
|
2283
|
-
try {
|
|
2284
|
-
JSON.parse(content);
|
|
2285
|
-
} catch (error) {
|
|
2286
|
-
throw new SourceLoadError("SYNTAX_ERROR", `${id} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, id);
|
|
2287
|
-
}
|
|
2288
|
-
return { id, body: `module.exports = JSON.parse(${JSON.stringify(content)});`, map: null, requires: {} };
|
|
2289
|
-
}
|
|
2290
|
-
const problems = findForbiddenSourceSyntax(ts, id, content);
|
|
2291
|
-
const syntax = problems.find((problem) => problem.kind === "syntax");
|
|
2292
|
-
if (syntax)
|
|
2293
|
-
throw new SourceLoadError("SYNTAX_ERROR", `${id}:${syntax.line}:${syntax.column + 1} ${syntax.message}`, id);
|
|
2294
|
-
const forbidden = problems[0];
|
|
2295
|
-
if (forbidden)
|
|
2296
|
-
throw new SourceLoadError("IMPORT_FORBIDDEN", `${id}:${forbidden.line}:${forbidden.column + 1} ${forbidden.message}`, id);
|
|
2297
|
-
for (const entry of collectSourceSpecifiers(ts, id, content)) {
|
|
2298
|
-
const kind = classifySpecifier(entry.specifier, id);
|
|
2299
|
-
if (kind.kind === "escape")
|
|
2300
|
-
throw new SourceLoadError("PATH_ESCAPE", `${id}:${entry.line} import '${entry.specifier}' escapes the connector source root`, id);
|
|
2301
|
-
if (kind.kind === "forbidden")
|
|
2302
|
-
throw new SourceLoadError("IMPORT_FORBIDDEN", `${id}:${entry.line} ${kind.message}`, id);
|
|
2303
|
-
}
|
|
2304
|
-
const { code, map } = transpile(ts, id, content);
|
|
2305
|
-
const requires = {};
|
|
2306
|
-
for (const entry of collectRequires(ts, id, code)) {
|
|
2307
|
-
if (entry.specifier === null) {
|
|
2308
|
-
throw new SourceLoadError("IMPORT_FORBIDDEN", `${id}: imports must use a literal path (computed require / import() is not allowed)`, id);
|
|
2309
|
-
}
|
|
2310
|
-
const specifier = entry.specifier;
|
|
2311
|
-
if (Object.hasOwn(requires, specifier))
|
|
2312
|
-
continue;
|
|
2313
|
-
const kind = classifySpecifier(specifier, id);
|
|
2314
|
-
switch (kind.kind) {
|
|
2315
|
-
case "sdk":
|
|
2316
|
-
requires[specifier] = null;
|
|
2317
|
-
break;
|
|
2318
|
-
case "escape":
|
|
2319
|
-
throw new SourceLoadError("PATH_ESCAPE", `${id}: import '${specifier}' escapes the connector source root`, id);
|
|
2320
|
-
case "forbidden":
|
|
2321
|
-
throw new SourceLoadError("IMPORT_FORBIDDEN", `${id}: ${kind.message}`, id);
|
|
2322
|
-
case "relative": {
|
|
2323
|
-
const resolved = resolveModule(files, kind.target);
|
|
2324
|
-
if (resolved === null)
|
|
2325
|
-
throw new SourceLoadError("IMPORT_FORBIDDEN", `${id}: cannot resolve import '${specifier}'`, id);
|
|
2326
|
-
requires[specifier] = resolved;
|
|
2327
|
-
break;
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
2330
|
-
}
|
|
2331
|
-
return { id, body: code, map, requires };
|
|
2332
|
-
}
|
|
2333
|
-
function countLines(text) {
|
|
2334
|
-
let lines = 0;
|
|
2335
|
-
for (let i = 0;i < text.length; i += 1)
|
|
2336
|
-
if (text.charCodeAt(i) === 10)
|
|
2337
|
-
lines += 1;
|
|
2338
|
-
return lines;
|
|
2339
|
-
}
|
|
2340
|
-
function assemble(modules, entry) {
|
|
2341
|
-
const lines = ["var __ontosModules = {"];
|
|
2342
|
-
const sections = [];
|
|
2343
|
-
let line = 1;
|
|
2344
|
-
for (const module2 of modules) {
|
|
2345
|
-
lines.push(`${JSON.stringify(module2.id)}: [${JSON.stringify(module2.requires)}, function (module, exports, require) {`);
|
|
2346
|
-
line += 1;
|
|
2347
|
-
if (module2.map)
|
|
2348
|
-
sections.push({ offset: { line, column: 0 }, map: module2.map });
|
|
2349
|
-
const body = module2.body.endsWith(`
|
|
2350
|
-
`) ? module2.body.slice(0, -1) : module2.body;
|
|
2351
|
-
lines.push(body);
|
|
2352
|
-
line += countLines(body) + 1;
|
|
2353
|
-
lines.push("}],");
|
|
2354
|
-
line += 1;
|
|
2355
|
-
}
|
|
2356
|
-
lines.push("};", "var __ontosCache = {};", "function __ontosRequire(id) {", " var cached = __ontosCache[id];", " if (cached) return cached.exports;", " var entry = __ontosModules[id];", " var mod = { exports: {} };", " __ontosCache[id] = mod;", " var table = entry[0];", " entry[1].call(mod.exports, mod, mod.exports, function (specifier) {", ' if (!Object.prototype.hasOwnProperty.call(table, specifier)) throw new Error("cannot require " + String(specifier) + " from " + id);', " var target = table[specifier];", " return target === null ? require(specifier) : __ontosRequire(target);", " });", " return mod.exports;", "}", `module.exports = __ontosRequire(${JSON.stringify(entry)});`);
|
|
2357
|
-
return {
|
|
2358
|
-
code: lines.join(`
|
|
2359
|
-
`),
|
|
2360
|
-
sourcemap: JSON.stringify({ version: 3, file: "connector.js", sections })
|
|
2361
|
-
};
|
|
2362
|
-
}
|
|
2363
|
-
async function loadConnectorSource(dir, options) {
|
|
2364
|
-
const ts = asTypeScript(options.typescript);
|
|
2365
|
-
const root = import_node_path4.default.resolve(dir);
|
|
2366
|
-
const stat = await import_promises3.lstat(root).catch(() => null);
|
|
2367
|
-
if (!stat)
|
|
2368
|
-
throw new SourceLoadError("MANIFEST_INVALID", `connector source directory not found: ${dir}`);
|
|
2369
|
-
if (stat.isSymbolicLink())
|
|
2370
|
-
throw new SourceLoadError("SYMLINK", `the connector source directory is a symbolic link: ${dir}`);
|
|
2371
|
-
if (!stat.isDirectory())
|
|
2372
|
-
throw new SourceLoadError("MANIFEST_INVALID", `not a directory: ${dir}`);
|
|
2373
|
-
const files = await collectFiles(root);
|
|
2374
|
-
const manifest = readManifest(files);
|
|
2375
|
-
const modules = [];
|
|
2376
|
-
const seen = new Set;
|
|
2377
|
-
const queue = [manifest.main];
|
|
2378
|
-
while (queue.length > 0) {
|
|
2379
|
-
const id = queue.shift();
|
|
2380
|
-
if (seen.has(id))
|
|
2381
|
-
continue;
|
|
2382
|
-
seen.add(id);
|
|
2383
|
-
const module2 = loadModule(ts, files, id);
|
|
2384
|
-
modules.push(module2);
|
|
2385
|
-
for (const target of Object.values(module2.requires))
|
|
2386
|
-
if (target !== null && !seen.has(target))
|
|
2387
|
-
queue.push(target);
|
|
2388
|
-
}
|
|
2389
|
-
const { code, sourcemap } = assemble(modules, manifest.main);
|
|
2390
|
-
return { name: manifest.name, version: manifest.version, code, sourcemap, files, sdkRange: manifest.sdkRange };
|
|
2391
|
-
}
|
|
2392
|
-
// src/testing/fixtures.ts
|
|
2393
|
-
class FixtureFormatError extends Error {
|
|
2394
|
-
file;
|
|
2395
|
-
line;
|
|
2396
|
-
constructor(message, file, line = null) {
|
|
2397
|
-
super(line === null ? `${file}: ${message}` : `${file}:${line}: ${message}`);
|
|
2398
|
-
this.name = "FixtureFormatError";
|
|
2399
|
-
this.file = file;
|
|
2400
|
-
this.line = line;
|
|
2401
|
-
}
|
|
2402
|
-
}
|
|
2403
|
-
var FIXTURE_CASE_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
2404
|
-
var FIXTURES_DIR = "fixtures";
|
|
2405
|
-
var CASE_FILE = "case.json";
|
|
2406
|
-
var EXCHANGES_FILE = "exchanges.ndjson";
|
|
2407
|
-
var REQUESTS_FILE = "requests.ndjson";
|
|
2408
|
-
var CASE_FILES = new Set([CASE_FILE, EXCHANGES_FILE, REQUESTS_FILE]);
|
|
2409
|
-
|
|
2410
|
-
class ShapeError extends Error {
|
|
2411
|
-
}
|
|
2412
|
-
function isRecord8(value) {
|
|
2413
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2414
|
-
}
|
|
2415
|
-
function fail(message) {
|
|
2416
|
-
throw new ShapeError(message);
|
|
2417
|
-
}
|
|
2418
|
-
function onlyKeys(value, allowed, where) {
|
|
2419
|
-
for (const key of Object.keys(value)) {
|
|
2420
|
-
if (!allowed.includes(key))
|
|
2421
|
-
fail(`${where} has an unknown field "${key}"`);
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
function requireString(value, where) {
|
|
2425
|
-
if (typeof value !== "string")
|
|
2426
|
-
fail(`${where} must be a string`);
|
|
2427
|
-
return value;
|
|
2428
|
-
}
|
|
2429
|
-
function requireNonEmptyString(value, where) {
|
|
2430
|
-
const text = requireString(value, where);
|
|
2431
|
-
if (text === "")
|
|
2432
|
-
fail(`${where} must not be empty`);
|
|
2433
|
-
return text;
|
|
2434
|
-
}
|
|
2435
|
-
function requireRecord(value, where) {
|
|
2436
|
-
if (!isRecord8(value))
|
|
2437
|
-
fail(`${where} must be an object`);
|
|
2438
|
-
return value;
|
|
2439
|
-
}
|
|
2440
|
-
function requireStringRecord(value, where) {
|
|
2441
|
-
const record = requireRecord(value, where);
|
|
2442
|
-
for (const [key, item] of Object.entries(record)) {
|
|
2443
|
-
if (typeof item !== "string")
|
|
2444
|
-
fail(`${where}.${key} must be a string`);
|
|
2445
|
-
}
|
|
2446
|
-
return record;
|
|
2447
|
-
}
|
|
2448
|
-
function requireCount(value, where) {
|
|
2449
|
-
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
2450
|
-
fail(`${where} must be a non-negative integer`);
|
|
2451
|
-
return value;
|
|
2452
|
-
}
|
|
2453
|
-
function requireError(value, where) {
|
|
2454
|
-
if (value === null)
|
|
2455
|
-
return;
|
|
2456
|
-
const error = requireRecord(value, where);
|
|
2457
|
-
onlyKeys(error, ["code", "message"], where);
|
|
2458
|
-
requireString(error.code, `${where}.code`);
|
|
2459
|
-
requireString(error.message, `${where}.message`);
|
|
2460
|
-
}
|
|
2461
|
-
function requireEncoding(value, where) {
|
|
2462
|
-
if (value !== "utf8" && value !== "base64")
|
|
2463
|
-
fail(`${where} must be "utf8" or "base64"`);
|
|
2464
|
-
}
|
|
2465
|
-
function validateExpectation(value) {
|
|
2466
|
-
const expectation = requireRecord(value, "expect");
|
|
2467
|
-
onlyKeys(expectation, ["records", "removals", "rejected", "checkpoints", "tables"], "expect");
|
|
2468
|
-
for (const field of ["records", "removals"]) {
|
|
2469
|
-
if (expectation[field] === undefined)
|
|
2470
|
-
continue;
|
|
2471
|
-
for (const [table, count] of Object.entries(requireRecord(expectation[field], `expect.${field}`)))
|
|
2472
|
-
requireCount(count, `expect.${field}.${table}`);
|
|
2473
|
-
}
|
|
2474
|
-
if (expectation.rejected !== undefined)
|
|
2475
|
-
requireCount(expectation.rejected, "expect.rejected");
|
|
2476
|
-
if (expectation.checkpoints !== undefined)
|
|
2477
|
-
requireCount(expectation.checkpoints, "expect.checkpoints");
|
|
2478
|
-
if (expectation.tables !== undefined) {
|
|
2479
|
-
if (!Array.isArray(expectation.tables) || !expectation.tables.every((table) => typeof table === "string"))
|
|
2480
|
-
fail("expect.tables must be an array of strings");
|
|
2481
|
-
}
|
|
2482
|
-
}
|
|
2483
|
-
function validateCase(value) {
|
|
2484
|
-
const item = requireRecord(value, "case");
|
|
2485
|
-
const common = ["kind", "config", "secrets", "expect"];
|
|
2486
|
-
switch (item.kind) {
|
|
2487
|
-
case "pull":
|
|
2488
|
-
onlyKeys(item, [...common, "stream", "mode", "state", "streamConfig", "limit"], "case");
|
|
2489
|
-
requireNonEmptyString(item.stream, "stream");
|
|
2490
|
-
if (item.mode !== "full" && item.mode !== "incremental")
|
|
2491
|
-
fail('mode must be "full" or "incremental"');
|
|
2492
|
-
if (item.streamConfig !== undefined)
|
|
2493
|
-
requireRecord(item.streamConfig, "streamConfig");
|
|
2494
|
-
if (item.limit !== undefined && (typeof item.limit !== "number" || !Number.isSafeInteger(item.limit) || item.limit <= 0))
|
|
2495
|
-
fail("limit must be a positive integer");
|
|
2496
|
-
break;
|
|
2497
|
-
case "push":
|
|
2498
|
-
onlyKeys(item, [...common, "push"], "case");
|
|
2499
|
-
requireNonEmptyString(item.push, "push");
|
|
2500
|
-
break;
|
|
2501
|
-
case "discover":
|
|
2502
|
-
onlyKeys(item, common, "case");
|
|
2503
|
-
break;
|
|
2504
|
-
default:
|
|
2505
|
-
fail('kind must be one of "pull", "push", "discover"');
|
|
2506
|
-
}
|
|
2507
|
-
requireRecord(item.config, "config");
|
|
2508
|
-
if (item.secrets !== undefined)
|
|
2509
|
-
requireStringRecord(item.secrets, "secrets");
|
|
2510
|
-
if (item.expect !== undefined)
|
|
2511
|
-
validateExpectation(item.expect);
|
|
2512
|
-
return item;
|
|
2513
|
-
}
|
|
2514
|
-
function validateCryptoRequest(value) {
|
|
2515
|
-
const request = requireRecord(value, "request");
|
|
2516
|
-
requireString(request.data, "request.data");
|
|
2517
|
-
if (request.encoding !== "hex" && request.encoding !== "base64")
|
|
2518
|
-
fail('request.encoding must be "hex" or "base64"');
|
|
2519
|
-
switch (request.op) {
|
|
2520
|
-
case "hash":
|
|
2521
|
-
onlyKeys(request, ["op", "algorithm", "data", "encoding"], "request");
|
|
2522
|
-
if (!["md5", "sha1", "sha256", "sha512"].includes(String(request.algorithm)))
|
|
2523
|
-
fail("request.algorithm is not a hash algorithm");
|
|
2524
|
-
return;
|
|
2525
|
-
case "hmac":
|
|
2526
|
-
onlyKeys(request, ["op", "algorithm", "key", "data", "encoding"], "request");
|
|
2527
|
-
requireString(request.key, "request.key");
|
|
2528
|
-
if (!["sha1", "sha256", "sha512"].includes(String(request.algorithm)))
|
|
2529
|
-
fail("request.algorithm is not an hmac algorithm");
|
|
2530
|
-
return;
|
|
2531
|
-
case "sign":
|
|
2532
|
-
onlyKeys(request, ["op", "algorithm", "key", "data", "encoding"], "request");
|
|
2533
|
-
requireString(request.key, "request.key");
|
|
2534
|
-
if (request.algorithm !== "rsa-sha256")
|
|
2535
|
-
fail('request.algorithm must be "rsa-sha256"');
|
|
2536
|
-
return;
|
|
2537
|
-
default:
|
|
2538
|
-
fail('request.op must be one of "hmac", "sign", "hash"');
|
|
2539
|
-
}
|
|
2540
|
-
}
|
|
2541
|
-
function validateExchange(value) {
|
|
2542
|
-
const item = requireRecord(value, "exchange");
|
|
2543
|
-
if (typeof item.seq !== "number" || !Number.isSafeInteger(item.seq) || item.seq <= 0)
|
|
2544
|
-
fail("seq must be a positive integer");
|
|
2545
|
-
switch (item.kind) {
|
|
2546
|
-
case "http": {
|
|
2547
|
-
onlyKeys(item, ["seq", "kind", "request", "response", "error"], "exchange");
|
|
2548
|
-
const request = requireRecord(item.request, "request");
|
|
2549
|
-
onlyKeys(request, ["method", "url", "headers", "body", "bodyEncoding"], "request");
|
|
2550
|
-
requireNonEmptyString(request.method, "request.method");
|
|
2551
|
-
requireNonEmptyString(request.url, "request.url");
|
|
2552
|
-
requireStringRecord(request.headers, "request.headers");
|
|
2553
|
-
if (request.body !== null)
|
|
2554
|
-
requireString(request.body, "request.body");
|
|
2555
|
-
requireEncoding(request.bodyEncoding, "request.bodyEncoding");
|
|
2556
|
-
if (item.response !== null) {
|
|
2557
|
-
const response = requireRecord(item.response, "response");
|
|
2558
|
-
onlyKeys(response, ["status", "headers", "body", "bodyEncoding"], "response");
|
|
2559
|
-
if (typeof response.status !== "number" || !Number.isInteger(response.status))
|
|
2560
|
-
fail("response.status must be an integer");
|
|
2561
|
-
requireStringRecord(response.headers, "response.headers");
|
|
2562
|
-
requireString(response.body, "response.body");
|
|
2563
|
-
requireEncoding(response.bodyEncoding, "response.bodyEncoding");
|
|
2564
|
-
}
|
|
2565
|
-
requireError(item.error, "error");
|
|
2566
|
-
if (item.response === null === (item.error === null))
|
|
2567
|
-
fail("exactly one of response and error must be null");
|
|
2568
|
-
break;
|
|
2569
|
-
}
|
|
2570
|
-
case "db": {
|
|
2571
|
-
onlyKeys(item, ["seq", "kind", "sql", "params", "fields", "rows", "error"], "exchange");
|
|
2572
|
-
requireString(item.sql, "sql");
|
|
2573
|
-
if (!Array.isArray(item.params))
|
|
2574
|
-
fail("params must be an array");
|
|
2575
|
-
if (!Array.isArray(item.fields))
|
|
2576
|
-
fail("fields must be an array");
|
|
2577
|
-
for (const field of item.fields) {
|
|
2578
|
-
const entry = requireRecord(field, "fields[]");
|
|
2579
|
-
requireString(entry.name, "fields[].name");
|
|
2580
|
-
if (typeof entry.dataTypeId !== "number" || !Number.isInteger(entry.dataTypeId))
|
|
2581
|
-
fail("fields[].dataTypeId must be an integer");
|
|
2582
|
-
}
|
|
2583
|
-
if (!Array.isArray(item.rows) || !item.rows.every(isRecord8))
|
|
2584
|
-
fail("rows must be an array of objects");
|
|
2585
|
-
requireError(item.error, "error");
|
|
2586
|
-
break;
|
|
2587
|
-
}
|
|
2588
|
-
case "crypto":
|
|
2589
|
-
onlyKeys(item, ["seq", "kind", "request", "value"], "exchange");
|
|
2590
|
-
validateCryptoRequest(item.request);
|
|
2591
|
-
requireString(item.value, "value");
|
|
2592
|
-
break;
|
|
2593
|
-
default:
|
|
2594
|
-
fail('kind must be one of "http", "db", "crypto"');
|
|
2595
|
-
}
|
|
2596
|
-
return item;
|
|
2597
|
-
}
|
|
2598
|
-
function validateRequest(value) {
|
|
2599
|
-
const item = requireRecord(value, "request");
|
|
2600
|
-
onlyKeys(item, ["requestId", "receivedAt", "headers", "body"], "request");
|
|
2601
|
-
requireNonEmptyString(item.requestId, "requestId");
|
|
2602
|
-
requireNonEmptyString(item.receivedAt, "receivedAt");
|
|
2603
|
-
requireStringRecord(item.headers, "headers");
|
|
2604
|
-
requireString(item.body, "body");
|
|
2605
|
-
return item;
|
|
2606
|
-
}
|
|
2607
|
-
function parseJsonFile(file, validate) {
|
|
2608
|
-
let value;
|
|
2609
|
-
try {
|
|
2610
|
-
value = JSON.parse(file.content);
|
|
2611
|
-
} catch (error) {
|
|
2612
|
-
throw new FixtureFormatError(`not valid JSON (${error instanceof Error ? error.message : String(error)})`, file.path);
|
|
2613
|
-
}
|
|
2614
|
-
try {
|
|
2615
|
-
return validate(value);
|
|
2616
|
-
} catch (error) {
|
|
2617
|
-
if (error instanceof ShapeError)
|
|
2618
|
-
throw new FixtureFormatError(error.message, file.path);
|
|
2619
|
-
throw error;
|
|
2620
|
-
}
|
|
2621
|
-
}
|
|
2622
|
-
function parseNdjson(file, validate) {
|
|
2623
|
-
const items = [];
|
|
2624
|
-
const lines = file.content.split(`
|
|
2625
|
-
`);
|
|
2626
|
-
for (let index = 0;index < lines.length; index += 1) {
|
|
2627
|
-
const text = (lines[index] ?? "").replace(/\r$/, "");
|
|
2628
|
-
if (text.trim() === "")
|
|
2629
|
-
continue;
|
|
2630
|
-
const line = index + 1;
|
|
2631
|
-
let value;
|
|
2632
|
-
try {
|
|
2633
|
-
value = JSON.parse(text);
|
|
2634
|
-
} catch {
|
|
2635
|
-
throw new FixtureFormatError("not valid JSON", file.path, line);
|
|
2636
|
-
}
|
|
2637
|
-
try {
|
|
2638
|
-
items.push(validate(value, line));
|
|
2639
|
-
} catch (error) {
|
|
2640
|
-
if (error instanceof ShapeError)
|
|
2641
|
-
throw new FixtureFormatError(error.message, file.path, line);
|
|
2642
|
-
throw error;
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
return items;
|
|
2646
|
-
}
|
|
2647
|
-
function normalizePath(filePath) {
|
|
2648
|
-
return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
2649
|
-
}
|
|
2650
|
-
function parseFixtures(files) {
|
|
2651
|
-
const groups = new Map;
|
|
2652
|
-
for (const raw of files) {
|
|
2653
|
-
const file = { path: normalizePath(raw.path), content: raw.content };
|
|
2654
|
-
const parts = file.path.split("/");
|
|
2655
|
-
if (parts.some((part) => part === "" || part === "." || part === ".."))
|
|
2656
|
-
throw new FixtureFormatError("fixture paths must be plain relative paths", file.path);
|
|
2657
|
-
if (parts.some((part) => part.startsWith(".")))
|
|
2658
|
-
continue;
|
|
2659
|
-
if (parts.length !== 3 || parts[0] !== FIXTURES_DIR) {
|
|
2660
|
-
throw new FixtureFormatError(`unexpected file: fixture files live at ${FIXTURES_DIR}/<case>/{${[...CASE_FILES].join(",")}}`, file.path);
|
|
2661
|
-
}
|
|
2662
|
-
const name = parts[1] ?? "";
|
|
2663
|
-
const fileName = parts[2] ?? "";
|
|
2664
|
-
if (!FIXTURE_CASE_NAME_PATTERN.test(name)) {
|
|
2665
|
-
throw new FixtureFormatError(`case name "${name}" must match ${FIXTURE_CASE_NAME_PATTERN.source}`, file.path);
|
|
2666
|
-
}
|
|
2667
|
-
if (!CASE_FILES.has(fileName)) {
|
|
2668
|
-
throw new FixtureFormatError(`unexpected file in case "${name}": expected ${[...CASE_FILES].join(", ")}`, file.path);
|
|
2669
|
-
}
|
|
2670
|
-
const group = groups.get(name) ?? new Map;
|
|
2671
|
-
if (group.has(fileName))
|
|
2672
|
-
throw new FixtureFormatError("duplicate file", file.path);
|
|
2673
|
-
group.set(fileName, file);
|
|
2674
|
-
groups.set(name, group);
|
|
2675
|
-
}
|
|
2676
|
-
const fixtures = [];
|
|
2677
|
-
for (const name of [...groups.keys()].sort()) {
|
|
2678
|
-
const group = groups.get(name) ?? new Map;
|
|
2679
|
-
const casePath = `${FIXTURES_DIR}/${name}/${CASE_FILE}`;
|
|
2680
|
-
const caseFile = group.get(CASE_FILE);
|
|
2681
|
-
if (!caseFile)
|
|
2682
|
-
throw new FixtureFormatError(`case "${name}" has no ${CASE_FILE}`, casePath);
|
|
2683
|
-
const fixtureCase = parseJsonFile(caseFile, validateCase);
|
|
2684
|
-
const exchangesFile = group.get(EXCHANGES_FILE);
|
|
2685
|
-
let previousSeq = 0;
|
|
2686
|
-
const exchanges = exchangesFile ? parseNdjson(exchangesFile, (value) => {
|
|
2687
|
-
const exchange = validateExchange(value);
|
|
2688
|
-
if (exchange.seq <= previousSeq)
|
|
2689
|
-
fail(`seq ${exchange.seq} must be greater than the previous seq ${previousSeq}`);
|
|
2690
|
-
previousSeq = exchange.seq;
|
|
2691
|
-
return exchange;
|
|
2692
|
-
}) : [];
|
|
2693
|
-
const requestsFile = group.get(REQUESTS_FILE);
|
|
2694
|
-
if (fixtureCase.kind === "push" && !requestsFile) {
|
|
2695
|
-
throw new FixtureFormatError(`push case "${name}" needs ${REQUESTS_FILE} next to ${CASE_FILE}`, casePath);
|
|
2696
|
-
}
|
|
2697
|
-
if (fixtureCase.kind !== "push" && requestsFile) {
|
|
2698
|
-
throw new FixtureFormatError(`${REQUESTS_FILE} only belongs to push cases`, requestsFile.path);
|
|
2699
|
-
}
|
|
2700
|
-
const seen = new Set;
|
|
2701
|
-
const requests = requestsFile ? parseNdjson(requestsFile, (value) => {
|
|
2702
|
-
const request = validateRequest(value);
|
|
2703
|
-
if (seen.has(request.requestId))
|
|
2704
|
-
fail(`requestId "${request.requestId}" appears more than once`);
|
|
2705
|
-
seen.add(request.requestId);
|
|
2706
|
-
return request;
|
|
2707
|
-
}) : [];
|
|
2708
|
-
fixtures.push({ name, case: fixtureCase, exchanges, requests });
|
|
2709
|
-
}
|
|
2710
|
-
return fixtures;
|
|
2711
|
-
}
|
|
2712
|
-
function redactCaseSecrets(fixtureCase) {
|
|
2713
|
-
if (!fixtureCase.secrets)
|
|
2714
|
-
return fixtureCase;
|
|
2715
|
-
const secrets = Object.fromEntries(Object.entries(fixtureCase.secrets).map(([field, value]) => [field, value.length >= REDACT_MIN_LENGTH ? REDACTED : value]));
|
|
2716
|
-
return { ...fixtureCase, secrets };
|
|
2717
|
-
}
|
|
2718
|
-
function ndjson(items) {
|
|
2719
|
-
return items.map((item) => `${JSON.stringify(item)}
|
|
2720
|
-
`).join("");
|
|
2721
|
-
}
|
|
2722
|
-
function serializeFixture(name, input) {
|
|
2723
|
-
const dir = `${FIXTURES_DIR}/${name}`;
|
|
2724
|
-
if (!FIXTURE_CASE_NAME_PATTERN.test(name))
|
|
2725
|
-
throw new FixtureFormatError(`case name "${name}" must match ${FIXTURE_CASE_NAME_PATTERN.source}`, `${dir}/${CASE_FILE}`);
|
|
2726
|
-
const requests = input.requests ?? [];
|
|
2727
|
-
if (input.case.kind !== "push" && requests.length > 0)
|
|
2728
|
-
throw new FixtureFormatError(`${REQUESTS_FILE} only belongs to push cases`, `${dir}/${REQUESTS_FILE}`);
|
|
2729
|
-
const files = [{ path: `${dir}/${CASE_FILE}`, content: `${JSON.stringify(redactCaseSecrets(input.case), null, 2)}
|
|
2730
|
-
` }];
|
|
2731
|
-
if (input.exchanges.length > 0)
|
|
2732
|
-
files.push({ path: `${dir}/${EXCHANGES_FILE}`, content: ndjson(input.exchanges) });
|
|
2733
|
-
if (input.case.kind === "push")
|
|
2734
|
-
files.push({ path: `${dir}/${REQUESTS_FILE}`, content: ndjson(requests) });
|
|
2735
|
-
return files;
|
|
2736
|
-
}
|
|
2737
|
-
async function readFixtureDir(dir) {
|
|
2738
|
-
const fs = await import("node:fs/promises");
|
|
2739
|
-
const path = await import("node:path");
|
|
2740
|
-
const root = path.join(dir, FIXTURES_DIR);
|
|
2741
|
-
const files = [];
|
|
2742
|
-
const walk = async (absolute, relative) => {
|
|
2743
|
-
const entries = await fs.readdir(absolute, { withFileTypes: true });
|
|
2744
|
-
for (const entry of entries) {
|
|
2745
|
-
if (entry.name.startsWith("."))
|
|
2746
|
-
continue;
|
|
2747
|
-
const childAbsolute = path.join(absolute, entry.name);
|
|
2748
|
-
const childRelative = `${relative}/${entry.name}`;
|
|
2749
|
-
if (entry.isSymbolicLink())
|
|
2750
|
-
throw new FixtureFormatError("symbolic links are not allowed in fixtures", childRelative);
|
|
2751
|
-
if (entry.isDirectory())
|
|
2752
|
-
await walk(childAbsolute, childRelative);
|
|
2753
|
-
else if (entry.isFile())
|
|
2754
|
-
files.push({ path: childRelative, content: await fs.readFile(childAbsolute, "utf8") });
|
|
2755
|
-
}
|
|
2756
|
-
};
|
|
2757
|
-
let stat;
|
|
2758
|
-
try {
|
|
2759
|
-
stat = await fs.lstat(root);
|
|
2760
|
-
} catch (error) {
|
|
2761
|
-
if (isRecord8(error) && error.code === "ENOENT")
|
|
2762
|
-
return [];
|
|
2763
|
-
throw error;
|
|
2764
|
-
}
|
|
2765
|
-
if (stat.isSymbolicLink())
|
|
2766
|
-
throw new FixtureFormatError("symbolic links are not allowed in fixtures", FIXTURES_DIR);
|
|
2767
|
-
if (!stat.isDirectory())
|
|
2768
|
-
throw new FixtureFormatError("must be a directory", FIXTURES_DIR);
|
|
2769
|
-
await walk(root, FIXTURES_DIR);
|
|
2770
|
-
return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
2771
|
-
}
|
|
2772
|
-
function canonicalRequestUrl(url, query) {
|
|
2773
|
-
let parsed;
|
|
2774
|
-
try {
|
|
2775
|
-
parsed = new URL(url);
|
|
2776
|
-
} catch {
|
|
2777
|
-
return url;
|
|
2778
|
-
}
|
|
2779
|
-
for (const [name, value] of Object.entries(query ?? {})) {
|
|
2780
|
-
for (const item of Array.isArray(value) ? value : [value])
|
|
2781
|
-
parsed.searchParams.append(name, item);
|
|
2782
|
-
}
|
|
2783
|
-
parsed.searchParams.sort();
|
|
2784
|
-
return parsed.toString();
|
|
2785
|
-
}
|
|
2786
|
-
function canonicalJson(value) {
|
|
2787
|
-
return JSON.stringify(value, (_key, item) => {
|
|
2788
|
-
if (!isRecord8(item))
|
|
2789
|
-
return item;
|
|
2790
|
-
return Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]]));
|
|
2791
|
-
});
|
|
2792
|
-
}
|
|
2793
|
-
// src/testing/recorder.ts
|
|
2794
|
-
function isRecord9(value) {
|
|
2795
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2796
|
-
}
|
|
2797
|
-
function redactDeep(value, secrets) {
|
|
2798
|
-
const plain = value === undefined ? null : JSON.parse(JSON.stringify(value));
|
|
2799
|
-
const walk = (item) => {
|
|
2800
|
-
if (typeof item === "string")
|
|
2801
|
-
return redactSecretValues(item, secrets);
|
|
2802
|
-
if (Array.isArray(item))
|
|
2803
|
-
return item.map(walk);
|
|
2804
|
-
if (isRecord9(item))
|
|
2805
|
-
return Object.fromEntries(Object.entries(item).map(([key, child]) => [key, walk(child)]));
|
|
2806
|
-
return item;
|
|
2807
|
-
};
|
|
2808
|
-
return walk(plain);
|
|
2809
|
-
}
|
|
2810
|
-
function createRecorder(options) {
|
|
2811
|
-
const secrets = secretRedactionTexts([...Object.values(options.secrets), ...options.extraSecrets ?? []].filter((value) => typeof value === "string"));
|
|
2812
|
-
const redact = (text) => redactSecretValues(text, secrets);
|
|
2813
|
-
const recorded = [];
|
|
2814
|
-
const openCursors = new Map;
|
|
2815
|
-
let seq = 0;
|
|
2816
|
-
const errorOf = (exchange) => exchange.error ? { code: exchange.error.code, message: redact(exchange.error.message) } : null;
|
|
2817
|
-
const recordHttp = (exchange) => {
|
|
2818
|
-
const request = exchange.request;
|
|
2819
|
-
if (!isRecord9(request) || typeof request.url !== "string" || typeof request.method !== "string")
|
|
2820
|
-
return;
|
|
2821
|
-
const bodyEncoding = request.bodyEncoding === "base64" ? "base64" : "utf8";
|
|
2822
|
-
const body = typeof request.body === "string" ? bodyEncoding === "utf8" ? redact(request.body) : request.body : null;
|
|
2823
|
-
const response = exchange.response;
|
|
2824
|
-
seq += 1;
|
|
2825
|
-
recorded.push({
|
|
2826
|
-
seq,
|
|
2827
|
-
kind: "http",
|
|
2828
|
-
request: {
|
|
2829
|
-
method: request.method,
|
|
2830
|
-
url: redactUrl(canonicalRequestUrl(request.url, request.query), secrets),
|
|
2831
|
-
headers: redactHeaders(isRecord9(request.headers) ? request.headers : {}, secrets),
|
|
2832
|
-
body,
|
|
2833
|
-
bodyEncoding
|
|
2834
|
-
},
|
|
2835
|
-
response: exchange.error || !isRecord9(response) ? null : {
|
|
2836
|
-
status: response.status,
|
|
2837
|
-
headers: redactHeaders(response.headers ?? {}, secrets),
|
|
2838
|
-
body: response.bodyEncoding === "utf8" ? redact(response.body) : response.body,
|
|
2839
|
-
bodyEncoding: response.bodyEncoding
|
|
2840
|
-
},
|
|
2841
|
-
error: errorOf(exchange) ?? (isRecord9(response) ? null : { code: "HOST_CALL_FAILED", message: "no response was recorded" })
|
|
2842
|
-
});
|
|
2843
|
-
};
|
|
2844
|
-
const recordDb = (exchange) => {
|
|
2845
|
-
const request = exchange.request;
|
|
2846
|
-
if (!isRecord9(request))
|
|
2847
|
-
return;
|
|
2848
|
-
if (typeof request.sql === "string") {
|
|
2849
|
-
const response = exchange.response;
|
|
2850
|
-
seq += 1;
|
|
2851
|
-
const entry = {
|
|
2852
|
-
seq,
|
|
2853
|
-
kind: "db",
|
|
2854
|
-
sql: redact(request.sql),
|
|
2855
|
-
params: redactDeep(Array.isArray(request.params) ? request.params : [], secrets),
|
|
2856
|
-
fields: isRecord9(response) && Array.isArray(response.fields) ? response.fields.map((field) => ({ name: field.name, dataTypeId: field.dataTypeId })) : [],
|
|
2857
|
-
rows: isRecord9(response) && Array.isArray(response.rows) ? redactDeep(response.rows, secrets) : [],
|
|
2858
|
-
error: errorOf(exchange)
|
|
2859
|
-
};
|
|
2860
|
-
recorded.push(entry);
|
|
2861
|
-
if (isRecord9(response) && typeof response.cursorId === "string" && response.done === false)
|
|
2862
|
-
openCursors.set(response.cursorId, entry);
|
|
2863
|
-
return;
|
|
2864
|
-
}
|
|
2865
|
-
if (typeof request.cursorId === "string") {
|
|
2866
|
-
const entry = openCursors.get(request.cursorId);
|
|
2867
|
-
if (!entry)
|
|
2868
|
-
return;
|
|
2869
|
-
const response = exchange.response;
|
|
2870
|
-
if (isRecord9(response) && Array.isArray(response.rows))
|
|
2871
|
-
entry.rows.push(...redactDeep(response.rows, secrets));
|
|
2872
|
-
const error = errorOf(exchange);
|
|
2873
|
-
if (error)
|
|
2874
|
-
entry.error = error;
|
|
2875
|
-
if (error || !isRecord9(response) || response.done !== false)
|
|
2876
|
-
openCursors.delete(request.cursorId);
|
|
2877
|
-
}
|
|
2878
|
-
};
|
|
2879
|
-
const recordCrypto = (exchange) => {
|
|
2880
|
-
if (exchange.error || !isRecord9(exchange.request) || !isRecord9(exchange.response) || typeof exchange.response.value !== "string")
|
|
2881
|
-
return;
|
|
2882
|
-
const request = redactDeep(exchange.request, secrets);
|
|
2883
|
-
seq += 1;
|
|
2884
|
-
recorded.push({ seq, kind: "crypto", request, value: redact(exchange.response.value) });
|
|
2885
|
-
};
|
|
2886
|
-
return {
|
|
2887
|
-
onExchange(exchange) {
|
|
2888
|
-
switch (exchange.kind) {
|
|
2889
|
-
case "http":
|
|
2890
|
-
recordHttp(exchange);
|
|
2891
|
-
return;
|
|
2892
|
-
case "db":
|
|
2893
|
-
recordDb(exchange);
|
|
2894
|
-
return;
|
|
2895
|
-
case "crypto":
|
|
2896
|
-
recordCrypto(exchange);
|
|
2897
|
-
return;
|
|
2898
|
-
}
|
|
2899
|
-
},
|
|
2900
|
-
exchanges() {
|
|
2901
|
-
return JSON.parse(JSON.stringify(recorded));
|
|
2902
|
-
}
|
|
2903
|
-
};
|
|
2904
|
-
}
|
|
2905
|
-
// src/testing/replay.ts
|
|
2906
|
-
var import_node_crypto3 = require("node:crypto");
|
|
2907
|
-
|
|
2908
|
-
// src/testing/schema.ts
|
|
2909
|
-
var ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/i;
|
|
2910
|
-
var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2911
|
-
function validDate(year, month, day) {
|
|
2912
|
-
return month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
2913
|
-
}
|
|
2914
|
-
function parseIsoDateTime(value) {
|
|
2915
|
-
const match = ISO_DATE_TIME.exec(value);
|
|
2916
|
-
if (!match)
|
|
2917
|
-
return null;
|
|
2918
|
-
const [, year, month, day, hour, minute, second, offset] = match;
|
|
2919
|
-
if (!validDate(Number(year), Number(month), Number(day)) || Number(hour) > 23 || Number(minute) > 59 || Number(second ?? "0") > 59)
|
|
2920
|
-
return null;
|
|
2921
|
-
const local = value.slice(0, value.length - (offset?.length ?? 0)).replace(" ", "T").replace(/(\.\d{3})\d+$/, "$1");
|
|
2922
|
-
let zone = "Z";
|
|
2923
|
-
if (offset !== undefined && offset.toUpperCase() !== "Z") {
|
|
2924
|
-
const digits = offset.slice(1).replace(":", "");
|
|
2925
|
-
zone = `${offset[0]}${digits.slice(0, 2)}:${digits.length > 2 ? digits.slice(2) : "00"}`;
|
|
2926
|
-
}
|
|
2927
|
-
const parsed = Date.parse(`${local}${zone}`);
|
|
2928
|
-
return Number.isNaN(parsed) ? null : parsed;
|
|
2929
|
-
}
|
|
2930
|
-
function parseIsoInstant(value) {
|
|
2931
|
-
const dateTime = parseIsoDateTime(value);
|
|
2932
|
-
if (dateTime !== null)
|
|
2933
|
-
return dateTime;
|
|
2934
|
-
const match = ISO_DATE.exec(value);
|
|
2935
|
-
if (!match || !validDate(Number(match[1]), Number(match[2]), Number(match[3])))
|
|
2936
|
-
return null;
|
|
2937
|
-
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
2938
|
-
}
|
|
2939
|
-
function kindOf(value) {
|
|
2940
|
-
if (value === null || value === undefined)
|
|
2941
|
-
return null;
|
|
2942
|
-
if (typeof value === "number" || typeof value === "bigint")
|
|
2943
|
-
return "number";
|
|
2944
|
-
if (typeof value === "boolean")
|
|
2945
|
-
return "boolean";
|
|
2946
|
-
if (typeof value === "string")
|
|
2947
|
-
return parseIsoDateTime(value) !== null ? "timestamp" : "string";
|
|
2948
|
-
return "json";
|
|
2949
|
-
}
|
|
2950
|
-
function inferRecordSchema(rows) {
|
|
2951
|
-
const seen = new Map;
|
|
2952
|
-
for (const row of rows) {
|
|
2953
|
-
for (const [column, value] of Object.entries(row)) {
|
|
2954
|
-
const kinds = seen.get(column) ?? new Set;
|
|
2955
|
-
const kind = kindOf(value);
|
|
2956
|
-
if (kind)
|
|
2957
|
-
kinds.add(kind);
|
|
2958
|
-
seen.set(column, kinds);
|
|
2959
|
-
}
|
|
2960
|
-
}
|
|
2961
|
-
const schema = {};
|
|
2962
|
-
for (const [column, kinds] of seen) {
|
|
2963
|
-
if (kinds.size === 0)
|
|
2964
|
-
schema[column] = "null";
|
|
2965
|
-
else if (kinds.size === 1)
|
|
2966
|
-
schema[column] = [...kinds][0] ?? "json";
|
|
2967
|
-
else if (kinds.size === 2 && kinds.has("string") && kinds.has("timestamp"))
|
|
2968
|
-
schema[column] = "string";
|
|
2969
|
-
else
|
|
2970
|
-
schema[column] = "json";
|
|
2971
|
-
}
|
|
2972
|
-
return schema;
|
|
2973
|
-
}
|
|
2974
|
-
|
|
2975
|
-
// src/testing/compliance.ts
|
|
2976
|
-
var COMPLIANCE_CODES = Object.freeze([
|
|
2977
|
-
"PRIMARY_KEY_DUPLICATE",
|
|
2978
|
-
"RECORD_SCHEMA_MISMATCH",
|
|
2979
|
-
"UNDECLARED_TABLE",
|
|
2980
|
-
"CHECKPOINT_NOT_MONOTONIC",
|
|
2981
|
-
"FULL_READ_NOT_REPEATABLE",
|
|
2982
|
-
"FIXTURE_EXCHANGE_UNUSED",
|
|
2983
|
-
"FIXTURE_EXCHANGE_MISMATCH",
|
|
2984
|
-
"EXPECTATION_FAILED"
|
|
2985
|
-
]);
|
|
2986
|
-
function isRecord10(value) {
|
|
2987
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2988
|
-
}
|
|
2989
|
-
function tablesOf(value) {
|
|
2990
|
-
const tables = {};
|
|
2991
|
-
if (!isRecord10(value))
|
|
2992
|
-
return tables;
|
|
2993
|
-
for (const [name, table] of Object.entries(value)) {
|
|
2994
|
-
if (!isRecord10(table) || !isRecord10(table.columns))
|
|
2995
|
-
continue;
|
|
2996
|
-
const columns = {};
|
|
2997
|
-
for (const [column, type] of Object.entries(table.columns)) {
|
|
2998
|
-
if (isRecord10(type) && typeof type.kind === "string")
|
|
2999
|
-
columns[column] = type;
|
|
3000
|
-
}
|
|
3001
|
-
const primaryKey = Array.isArray(table.primaryKey) ? table.primaryKey.filter((item) => typeof item === "string") : [];
|
|
3002
|
-
tables[name] = { columns, primaryKey };
|
|
3003
|
-
}
|
|
3004
|
-
return tables;
|
|
3005
|
-
}
|
|
3006
|
-
function entriesOf(value) {
|
|
3007
|
-
return isRecord10(value) ? Object.entries(value).filter((entry) => isRecord10(entry[1])) : [];
|
|
3008
|
-
}
|
|
3009
|
-
function scopedTables(declaration, scope) {
|
|
3010
|
-
if (!declaration || !isRecord10(declaration.discover))
|
|
3011
|
-
return null;
|
|
3012
|
-
const { streams, pushes } = declaration.discover;
|
|
3013
|
-
if (scope.stream !== undefined) {
|
|
3014
|
-
const stream = entriesOf(streams).find(([name]) => name === scope.stream)?.[1];
|
|
3015
|
-
return tablesOf(stream?.tables);
|
|
3016
|
-
}
|
|
3017
|
-
if (scope.push !== undefined) {
|
|
3018
|
-
const push = entriesOf(pushes).find(([name]) => name === scope.push)?.[1];
|
|
3019
|
-
return tablesOf(push?.tables);
|
|
3020
|
-
}
|
|
3021
|
-
const all = {};
|
|
3022
|
-
for (const [, item] of [...entriesOf(streams), ...entriesOf(pushes)])
|
|
3023
|
-
Object.assign(all, tablesOf(item.tables));
|
|
3024
|
-
return all;
|
|
3025
|
-
}
|
|
3026
|
-
var INSTANT_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}(?::?\d{2})?)$/i;
|
|
3027
|
-
var DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
3028
|
-
var INTEGER_PATTERN = /^-?\d+$/;
|
|
3029
|
-
var DECIMAL_PATTERN = /^-?(\d+)(?:\.(\d+))?$/;
|
|
3030
|
-
function validCalendarDate(year, month, day) {
|
|
3031
|
-
return month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
3032
|
-
}
|
|
3033
|
-
function columnValueProblem(type, value) {
|
|
3034
|
-
if (value === null || value === undefined)
|
|
3035
|
-
return null;
|
|
3036
|
-
switch (type.kind) {
|
|
3037
|
-
case "string":
|
|
3038
|
-
return typeof value === "string" ? null : "is not a valid string";
|
|
3039
|
-
case "integer":
|
|
3040
|
-
if (typeof value === "number" && Number.isSafeInteger(value))
|
|
3041
|
-
return null;
|
|
3042
|
-
if (typeof value === "bigint")
|
|
3043
|
-
return null;
|
|
3044
|
-
if (typeof value === "string" && INTEGER_PATTERN.test(value))
|
|
3045
|
-
return null;
|
|
3046
|
-
return "is not a valid integer";
|
|
3047
|
-
case "decimal": {
|
|
3048
|
-
let text;
|
|
3049
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
3050
|
-
text = String(value);
|
|
3051
|
-
else if (typeof value === "string")
|
|
3052
|
-
text = value;
|
|
3053
|
-
else
|
|
3054
|
-
return "is not a valid decimal";
|
|
3055
|
-
const match = DECIMAL_PATTERN.exec(text);
|
|
3056
|
-
if (!match)
|
|
3057
|
-
return "is not a valid decimal";
|
|
3058
|
-
if (type.precision !== undefined && type.scale !== undefined) {
|
|
3059
|
-
const integerDigits = (match[1] ?? "").replace(/^0+(?=\d)/, "");
|
|
3060
|
-
if (integerDigits !== "0" && integerDigits.length > type.precision - type.scale)
|
|
3061
|
-
return `exceeds decimal precision ${type.precision},${type.scale}`;
|
|
3062
|
-
}
|
|
3063
|
-
return null;
|
|
3064
|
-
}
|
|
3065
|
-
case "boolean":
|
|
3066
|
-
return typeof value === "boolean" ? null : "is not a valid boolean";
|
|
3067
|
-
case "date": {
|
|
3068
|
-
if (typeof value !== "string")
|
|
3069
|
-
return "is not a valid date";
|
|
3070
|
-
const match = DATE_PATTERN.exec(value);
|
|
3071
|
-
return match && validCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])) ? null : "is not a valid date";
|
|
3072
|
-
}
|
|
3073
|
-
case "instant": {
|
|
3074
|
-
if (typeof value !== "string")
|
|
3075
|
-
return "is not a valid instant";
|
|
3076
|
-
const match = INSTANT_PATTERN.exec(value);
|
|
3077
|
-
if (!match)
|
|
3078
|
-
return "is not an ISO 8601 timestamp with a time zone offset";
|
|
3079
|
-
const [, year, month, day, hour, minute, second] = match;
|
|
3080
|
-
const valid = validCalendarDate(Number(year), Number(month), Number(day)) && Number(hour) <= 23 && Number(minute) <= 59 && Number(second ?? "0") <= 59;
|
|
3081
|
-
return valid && parseIsoInstant(value) !== null ? null : "is not a valid point in time";
|
|
3082
|
-
}
|
|
3083
|
-
case "json":
|
|
3084
|
-
try {
|
|
3085
|
-
return JSON.stringify(value) === undefined ? "is not valid JSON" : null;
|
|
3086
|
-
} catch {
|
|
3087
|
-
return "is not valid JSON";
|
|
3088
|
-
}
|
|
3089
|
-
default:
|
|
3090
|
-
return `has an unsupported column type ${type.kind}`;
|
|
3091
|
-
}
|
|
3092
|
-
}
|
|
3093
|
-
|
|
3094
|
-
class FindingSet {
|
|
3095
|
-
items = new Map;
|
|
3096
|
-
ordered = [];
|
|
3097
|
-
add(key, build) {
|
|
3098
|
-
const existing = this.items.get(key);
|
|
3099
|
-
if (existing) {
|
|
3100
|
-
existing.count += 1;
|
|
3101
|
-
existing.finding.detail.count = existing.count;
|
|
3102
|
-
return;
|
|
3103
|
-
}
|
|
3104
|
-
const { code, message, table, sample, extra } = build();
|
|
3105
|
-
const finding = { code, message, ...table !== undefined ? { table } : {}, detail: { count: 1, ...sample !== undefined ? { sample } : {}, ...extra } };
|
|
3106
|
-
this.items.set(key, { finding, count: 1 });
|
|
3107
|
-
this.ordered.push(finding);
|
|
3108
|
-
}
|
|
3109
|
-
push(finding) {
|
|
3110
|
-
this.ordered.push(finding);
|
|
3111
|
-
}
|
|
3112
|
-
list() {
|
|
3113
|
-
return this.ordered;
|
|
3114
|
-
}
|
|
3115
|
-
}
|
|
3116
|
-
function checkRows(input, tables, findings) {
|
|
3117
|
-
const keysSeen = new Map;
|
|
3118
|
-
const undeclared = (table, sample) => {
|
|
3119
|
-
findings.add(`undeclared\x00${table}`, () => ({
|
|
3120
|
-
code: "UNDECLARED_TABLE",
|
|
3121
|
-
table,
|
|
3122
|
-
sample,
|
|
3123
|
-
message: `table "${table}" is not declared${input.stream !== undefined ? ` by stream "${input.stream}"` : input.push !== undefined ? ` by push "${input.push}"` : ""}`
|
|
3124
|
-
}));
|
|
3125
|
-
};
|
|
3126
|
-
const mismatch = (table, column, reason, sample) => {
|
|
3127
|
-
findings.add(`schema\x00${table}\x00${column}\x00${reason}`, () => ({
|
|
3128
|
-
code: "RECORD_SCHEMA_MISMATCH",
|
|
3129
|
-
table,
|
|
3130
|
-
sample,
|
|
3131
|
-
extra: { column },
|
|
3132
|
-
message: `column "${column}" of table "${table}" ${reason}`
|
|
3133
|
-
}));
|
|
3134
|
-
};
|
|
3135
|
-
for (const record of input.result.records) {
|
|
3136
|
-
const declared = Object.hasOwn(tables, record.table) ? tables[record.table] : undefined;
|
|
3137
|
-
if (!declared) {
|
|
3138
|
-
undeclared(record.table, record.row);
|
|
3139
|
-
continue;
|
|
3140
|
-
}
|
|
3141
|
-
for (const column of Object.keys(record.row)) {
|
|
3142
|
-
if (!Object.hasOwn(declared.columns, column))
|
|
3143
|
-
mismatch(record.table, column, "is not declared", record.row);
|
|
3144
|
-
}
|
|
3145
|
-
for (const [column, type] of Object.entries(declared.columns)) {
|
|
3146
|
-
const problem = columnValueProblem(type, record.row[column]);
|
|
3147
|
-
if (problem)
|
|
3148
|
-
mismatch(record.table, column, problem, record.row[column]);
|
|
3149
|
-
}
|
|
3150
|
-
const missing = declared.primaryKey.filter((column) => record.row[column] === null || record.row[column] === undefined);
|
|
3151
|
-
for (const column of missing)
|
|
3152
|
-
mismatch(record.table, column, "is a primary key column and is missing", record.row);
|
|
3153
|
-
if (declared.primaryKey.length === 0 || missing.length > 0)
|
|
3154
|
-
continue;
|
|
3155
|
-
const key = JSON.stringify(declared.primaryKey.map((column) => record.row[column]));
|
|
3156
|
-
const seen = keysSeen.get(record.table) ?? new Set;
|
|
3157
|
-
if (seen.has(key)) {
|
|
3158
|
-
const sample = Object.fromEntries(declared.primaryKey.map((column) => [column, record.row[column]]));
|
|
3159
|
-
findings.add(`pk\x00${record.table}`, () => ({
|
|
3160
|
-
code: "PRIMARY_KEY_DUPLICATE",
|
|
3161
|
-
table: record.table,
|
|
3162
|
-
sample,
|
|
3163
|
-
message: `table "${record.table}" received more than one record with the same primary key (${declared.primaryKey.join(", ")})`
|
|
3164
|
-
}));
|
|
3165
|
-
}
|
|
3166
|
-
seen.add(key);
|
|
3167
|
-
keysSeen.set(record.table, seen);
|
|
3168
|
-
}
|
|
3169
|
-
for (const removal of input.result.removals) {
|
|
3170
|
-
const declared = Object.hasOwn(tables, removal.table) ? tables[removal.table] : undefined;
|
|
3171
|
-
if (!declared) {
|
|
3172
|
-
undeclared(removal.table, removal.key);
|
|
3173
|
-
continue;
|
|
3174
|
-
}
|
|
3175
|
-
for (const column of Object.keys(removal.key)) {
|
|
3176
|
-
if (!declared.primaryKey.includes(column))
|
|
3177
|
-
mismatch(removal.table, column, "is not part of the primary key (removal)", removal.key);
|
|
3178
|
-
}
|
|
3179
|
-
for (const column of declared.primaryKey) {
|
|
3180
|
-
const value = removal.key[column];
|
|
3181
|
-
if (value === null || value === undefined) {
|
|
3182
|
-
mismatch(removal.table, column, "is a primary key column and is missing (removal)", removal.key);
|
|
3183
|
-
continue;
|
|
3184
|
-
}
|
|
3185
|
-
const type = declared.columns[column];
|
|
3186
|
-
const problem = type ? columnValueProblem(type, value) : "is not declared";
|
|
3187
|
-
if (problem)
|
|
3188
|
-
mismatch(removal.table, column, `${problem} (removal)`, value);
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
}
|
|
3192
|
-
function comparable(value) {
|
|
3193
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
3194
|
-
return { kind: "number", at: value };
|
|
3195
|
-
if (typeof value === "string") {
|
|
3196
|
-
const at = parseIsoInstant(value);
|
|
3197
|
-
if (at !== null)
|
|
3198
|
-
return { kind: "time", at };
|
|
3199
|
-
}
|
|
3200
|
-
return null;
|
|
3201
|
-
}
|
|
3202
|
-
function checkCheckpoints(checkpoints, findings) {
|
|
3203
|
-
for (let index = 1;index < checkpoints.length; index += 1) {
|
|
3204
|
-
const previous = checkpoints[index - 1];
|
|
3205
|
-
const next = checkpoints[index];
|
|
3206
|
-
if (!previous || !next || !isRecord10(previous.state) || !isRecord10(next.state))
|
|
3207
|
-
continue;
|
|
3208
|
-
for (const [field, before] of Object.entries(previous.state)) {
|
|
3209
|
-
if (!Object.hasOwn(next.state, field))
|
|
3210
|
-
continue;
|
|
3211
|
-
const after = next.state[field];
|
|
3212
|
-
const a = comparable(before);
|
|
3213
|
-
const b = comparable(after);
|
|
3214
|
-
if (!a || !b || a.kind !== b.kind || b.at >= a.at)
|
|
3215
|
-
continue;
|
|
3216
|
-
findings.push({
|
|
3217
|
-
code: "CHECKPOINT_NOT_MONOTONIC",
|
|
3218
|
-
message: `checkpoint field "${field}" went backwards from ${JSON.stringify(before)} to ${JSON.stringify(after)} (checkpoint ${next.seq})`,
|
|
3219
|
-
detail: { field, previous: before, next: after, seq: next.seq }
|
|
3220
|
-
});
|
|
3221
|
-
}
|
|
3222
|
-
}
|
|
3223
|
-
}
|
|
3224
|
-
function canonical(value) {
|
|
3225
|
-
return JSON.stringify(value, (_key, item) => isRecord10(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item);
|
|
3226
|
-
}
|
|
3227
|
-
function recordSet(records, tables) {
|
|
3228
|
-
return records.map((record) => {
|
|
3229
|
-
const primaryKey = tables && Object.hasOwn(tables, record.table) ? tables[record.table]?.primaryKey ?? [] : [];
|
|
3230
|
-
return `${record.table}\x00${canonical(primaryKey.map((column) => record.row[column]))}\x00${canonical(record.row)}`;
|
|
3231
|
-
}).sort();
|
|
3232
|
-
}
|
|
3233
|
-
function checkRepeatable(input, tables, findings) {
|
|
3234
|
-
const rerun = input.rerun;
|
|
3235
|
-
if (input.mode !== "full" || !rerun)
|
|
3236
|
-
return;
|
|
3237
|
-
if (rerun.error) {
|
|
3238
|
-
findings.push({
|
|
3239
|
-
code: "FULL_READ_NOT_REPEATABLE",
|
|
3240
|
-
message: `the second full read failed (${rerun.error.code}: ${rerun.error.message})`,
|
|
3241
|
-
detail: { error: rerun.error }
|
|
3242
|
-
});
|
|
3243
|
-
return;
|
|
3244
|
-
}
|
|
3245
|
-
const first = recordSet(input.result.records, tables);
|
|
3246
|
-
const second = recordSet(rerun.records, tables);
|
|
3247
|
-
if (first.length === second.length && first.every((item, index) => item === second[index]))
|
|
3248
|
-
return;
|
|
3249
|
-
const firstSet = new Set(first);
|
|
3250
|
-
const secondSet = new Set(second);
|
|
3251
|
-
findings.push({
|
|
3252
|
-
code: "FULL_READ_NOT_REPEATABLE",
|
|
3253
|
-
message: `two full reads returned different records (${first.length} vs ${second.length} record(s))`,
|
|
3254
|
-
detail: {
|
|
3255
|
-
firstCount: first.length,
|
|
3256
|
-
secondCount: second.length,
|
|
3257
|
-
onlyInFirst: first.filter((item) => !secondSet.has(item)).length,
|
|
3258
|
-
onlyInSecond: second.filter((item) => !firstSet.has(item)).length
|
|
3259
|
-
}
|
|
3260
|
-
});
|
|
3261
|
-
}
|
|
3262
|
-
function checkCompliance(input) {
|
|
3263
|
-
const findings = new FindingSet;
|
|
3264
|
-
const tables = scopedTables(input.declaration, { ...input.stream !== undefined ? { stream: input.stream } : {}, ...input.push !== undefined ? { push: input.push } : {} });
|
|
3265
|
-
if (tables)
|
|
3266
|
-
checkRows(input, tables, findings);
|
|
3267
|
-
checkCheckpoints(input.result.checkpoints, findings);
|
|
3268
|
-
checkRepeatable(input, tables, findings);
|
|
3269
|
-
return findings.list();
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
// src/testing/replay.ts
|
|
3273
|
-
class FixtureReplayError extends Error {
|
|
3274
|
-
code;
|
|
3275
|
-
constructor(code, message) {
|
|
3276
|
-
super(message);
|
|
3277
|
-
this.name = "FixtureReplayError";
|
|
3278
|
-
this.code = code;
|
|
3279
|
-
}
|
|
3280
|
-
}
|
|
3281
|
-
var MISMATCH = "FIXTURE_EXCHANGE_MISMATCH";
|
|
3282
|
-
var PLAIN_HANDLE2 = /ontos:secret:([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
3283
|
-
function isRecord11(value) {
|
|
3284
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3285
|
-
}
|
|
3286
|
-
function preview(text, max = 200) {
|
|
3287
|
-
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
3288
|
-
}
|
|
3289
|
-
function createReplayHost(fixture) {
|
|
3290
|
-
const caseSecrets = fixture.case.secrets ?? {};
|
|
3291
|
-
const secretValues = Object.values(caseSecrets);
|
|
3292
|
-
const redact = (text) => redactSecretValues(text, secretValues);
|
|
3293
|
-
const used = new Set;
|
|
3294
|
-
const mismatched = [];
|
|
3295
|
-
const failingCursors = new Map;
|
|
3296
|
-
const cache = new Map;
|
|
3297
|
-
const mismatch = (method, detail) => {
|
|
3298
|
-
mismatched.push({ method, detail });
|
|
3299
|
-
throw new FixtureReplayError(MISMATCH, `no recorded exchange in fixture "${fixture.name}" matches ${method} ${detail}`);
|
|
3300
|
-
};
|
|
3301
|
-
const take = (kind, matches) => {
|
|
3302
|
-
for (const exchange of fixture.exchanges) {
|
|
3303
|
-
if (exchange.kind !== kind || used.has(exchange.seq))
|
|
3304
|
-
continue;
|
|
3305
|
-
const typed = exchange;
|
|
3306
|
-
if (!matches(typed))
|
|
3307
|
-
continue;
|
|
3308
|
-
used.add(exchange.seq);
|
|
3309
|
-
return typed;
|
|
3310
|
-
}
|
|
3311
|
-
return null;
|
|
3312
|
-
};
|
|
3313
|
-
const lookupSecret = (field) => {
|
|
3314
|
-
const value = Object.hasOwn(caseSecrets, field) ? caseSecrets[field] : undefined;
|
|
3315
|
-
if (typeof value !== "string")
|
|
3316
|
-
throw new FixtureReplayError("EGRESS_SECRET_UNAVAILABLE", `Secret '${field}' is not in the fixture's case.json secrets`);
|
|
3317
|
-
return value;
|
|
3318
|
-
};
|
|
3319
|
-
const http = (payload) => {
|
|
3320
|
-
if (!isRecord11(payload) || typeof payload.method !== "string" || typeof payload.url !== "string") {
|
|
3321
|
-
throw new FixtureReplayError("BAD_REQUEST", "http request must have a method and a url");
|
|
3322
|
-
}
|
|
3323
|
-
const query = isRecord11(payload.query) ? payload.query : undefined;
|
|
3324
|
-
const scriptUrl = canonicalRequestUrl(payload.url, query);
|
|
3325
|
-
const url = redactUrl(scriptUrl, secretValues);
|
|
3326
|
-
const bodyEncoding = payload.bodyEncoding === "base64" ? "base64" : "utf8";
|
|
3327
|
-
const body = typeof payload.body === "string" ? bodyEncoding === "utf8" ? redact(payload.body) : payload.body : null;
|
|
3328
|
-
const method = payload.method;
|
|
3329
|
-
const exchange = take("http", (item) => item.request.method === method && item.request.url === url && item.request.body === body && (body === null || item.request.bodyEncoding === bodyEncoding));
|
|
3330
|
-
if (!exchange)
|
|
3331
|
-
return mismatch("http", `${method} ${url}${body !== null ? ` with body ${preview(body)}` : ""}`);
|
|
3332
|
-
if (exchange.error)
|
|
3333
|
-
throw new FixtureReplayError(exchange.error.code, exchange.error.message);
|
|
3334
|
-
if (!exchange.response)
|
|
3335
|
-
throw new FixtureReplayError("HOST_CALL_FAILED", "the recorded exchange has no response");
|
|
3336
|
-
return { status: exchange.response.status, url: scriptUrl, headers: { ...exchange.response.headers }, body: exchange.response.body, bodyEncoding: exchange.response.bodyEncoding };
|
|
3337
|
-
};
|
|
3338
|
-
const dbQuery = (payload) => {
|
|
3339
|
-
if (!isRecord11(payload) || typeof payload.sql !== "string")
|
|
3340
|
-
throw new FixtureReplayError("BAD_REQUEST", "db.query needs sql");
|
|
3341
|
-
const sql = redact(payload.sql);
|
|
3342
|
-
const params = canonicalJson(JSON.parse(redact(JSON.stringify(Array.isArray(payload.params) ? payload.params : []))));
|
|
3343
|
-
const exchange = take("db", (item) => item.sql === sql && canonicalJson(item.params) === params);
|
|
3344
|
-
if (!exchange)
|
|
3345
|
-
return mismatch("db.query", `${preview(sql.replace(/\s+/g, " ").trim())} with params ${preview(params)}`);
|
|
3346
|
-
const rows = exchange.rows.map((row) => ({ ...row }));
|
|
3347
|
-
const fields = exchange.fields.map((field) => ({ ...field }));
|
|
3348
|
-
if (!exchange.error)
|
|
3349
|
-
return { cursorId: null, fields, rows, done: true };
|
|
3350
|
-
if (fields.length === 0 && rows.length === 0)
|
|
3351
|
-
throw new FixtureReplayError(exchange.error.code, exchange.error.message);
|
|
3352
|
-
const cursorId = `replay-${exchange.seq}`;
|
|
3353
|
-
failingCursors.set(cursorId, exchange.error);
|
|
3354
|
-
return { cursorId, fields, rows, done: false };
|
|
3355
|
-
};
|
|
3356
|
-
const crypto = (payload) => {
|
|
3357
|
-
if (!isRecord11(payload) || typeof payload.op !== "string")
|
|
3358
|
-
throw new FixtureReplayError("BAD_REQUEST", "crypto request must have an op");
|
|
3359
|
-
const request = JSON.parse(redact(JSON.stringify(payload)));
|
|
3360
|
-
const key = canonicalJson(request);
|
|
3361
|
-
const exchange = take("crypto", (item) => canonicalJson(item.request) === key);
|
|
3362
|
-
if (exchange)
|
|
3363
|
-
return { value: exchange.value };
|
|
3364
|
-
if (request.op === "hash") {
|
|
3365
|
-
const data = request.data.replace(PLAIN_HANDLE2, (_match, field) => lookupSecret(field));
|
|
3366
|
-
return { value: import_node_crypto3.createHash(request.algorithm).update(data, "utf8").digest(request.encoding) };
|
|
3367
|
-
}
|
|
3368
|
-
return mismatch("crypto", `${request.op} ${request.algorithm}`);
|
|
3369
|
-
};
|
|
3370
|
-
const call = async (method, payload) => {
|
|
3371
|
-
switch (method) {
|
|
3372
|
-
case "http":
|
|
3373
|
-
return http(payload);
|
|
3374
|
-
case "db.query":
|
|
3375
|
-
return dbQuery(payload);
|
|
3376
|
-
case "db.next": {
|
|
3377
|
-
const cursorId = isRecord11(payload) && typeof payload.cursorId === "string" ? payload.cursorId : "";
|
|
3378
|
-
const error = failingCursors.get(cursorId);
|
|
3379
|
-
if (!error)
|
|
3380
|
-
throw new FixtureReplayError("DB_CURSOR_NOT_FOUND", "The cursor doesn't exist or was closed");
|
|
3381
|
-
failingCursors.delete(cursorId);
|
|
3382
|
-
throw new FixtureReplayError(error.code, error.message);
|
|
3383
|
-
}
|
|
3384
|
-
case "db.close": {
|
|
3385
|
-
const cursorId = isRecord11(payload) && typeof payload.cursorId === "string" ? payload.cursorId : "";
|
|
3386
|
-
failingCursors.delete(cursorId);
|
|
3387
|
-
return null;
|
|
3388
|
-
}
|
|
3389
|
-
case "crypto":
|
|
3390
|
-
return crypto(payload);
|
|
3391
|
-
case "cache.get": {
|
|
3392
|
-
const key = isRecord11(payload) && typeof payload.key === "string" ? payload.key : "";
|
|
3393
|
-
const entry = cache.get(key);
|
|
3394
|
-
if (!entry || Date.parse(entry.expiresAt) <= Date.now())
|
|
3395
|
-
return null;
|
|
3396
|
-
return entry;
|
|
3397
|
-
}
|
|
3398
|
-
case "cache.set": {
|
|
3399
|
-
if (!isRecord11(payload) || typeof payload.key !== "string" || typeof payload.value !== "string" || typeof payload.ttlSeconds !== "number") {
|
|
3400
|
-
throw new FixtureReplayError("BAD_REQUEST", "cache.set needs key, value and ttlSeconds");
|
|
3401
|
-
}
|
|
3402
|
-
cache.set(payload.key, { value: payload.value, expiresAt: new Date(Date.now() + payload.ttlSeconds * 1000).toISOString() });
|
|
3403
|
-
return null;
|
|
3404
|
-
}
|
|
3405
|
-
case "secret.reveal": {
|
|
3406
|
-
const field = isRecord11(payload) && typeof payload.field === "string" ? payload.field : "";
|
|
3407
|
-
return { value: lookupSecret(field) };
|
|
3408
|
-
}
|
|
3409
|
-
default:
|
|
3410
|
-
throw new FixtureReplayError("HOST_METHOD_UNSUPPORTED", `fixture replay does not answer ${method}`);
|
|
3411
|
-
}
|
|
3412
|
-
};
|
|
3413
|
-
return {
|
|
3414
|
-
call,
|
|
3415
|
-
redact,
|
|
3416
|
-
async close() {
|
|
3417
|
-
failingCursors.clear();
|
|
3418
|
-
},
|
|
3419
|
-
unused() {
|
|
3420
|
-
return fixture.exchanges.filter((exchange) => !used.has(exchange.seq)).map((exchange) => JSON.parse(JSON.stringify(exchange)));
|
|
3421
|
-
},
|
|
3422
|
-
mismatches() {
|
|
3423
|
-
return mismatched.map((item) => ({ ...item }));
|
|
3424
|
-
}
|
|
3425
|
-
};
|
|
3426
|
-
}
|
|
3427
|
-
function countBy(items) {
|
|
3428
|
-
const counts = {};
|
|
3429
|
-
for (const item of items)
|
|
3430
|
-
counts[item.table] = (counts[item.table] ?? 0) + 1;
|
|
3431
|
-
return counts;
|
|
3432
|
-
}
|
|
3433
|
-
function toDeclaration(declaration) {
|
|
3434
|
-
if (!declaration || !isRecord11(declaration.discover))
|
|
3435
|
-
return null;
|
|
3436
|
-
const discover = declaration.discover;
|
|
3437
|
-
return { definition: declaration.summary, streams: discover.streams, pushes: discover.pushes };
|
|
3438
|
-
}
|
|
3439
|
-
function exchangeSummary(exchange) {
|
|
3440
|
-
switch (exchange.kind) {
|
|
3441
|
-
case "http":
|
|
3442
|
-
return `${exchange.request.method} ${exchange.request.url}`;
|
|
3443
|
-
case "db":
|
|
3444
|
-
return preview(exchange.sql.replace(/\s+/g, " ").trim());
|
|
3445
|
-
case "crypto":
|
|
3446
|
-
return `${exchange.request.op} ${exchange.request.algorithm}`;
|
|
3447
|
-
}
|
|
3448
|
-
}
|
|
3449
|
-
function scopeOf(fixtureCase) {
|
|
3450
|
-
if (fixtureCase.kind === "pull")
|
|
3451
|
-
return { stream: fixtureCase.stream };
|
|
3452
|
-
if (fixtureCase.kind === "push")
|
|
3453
|
-
return { push: fixtureCase.push };
|
|
3454
|
-
return {};
|
|
3455
|
-
}
|
|
3456
|
-
function sameCounts(expected, actual) {
|
|
3457
|
-
return Object.entries(expected).filter(([table, count]) => (actual[table] ?? 0) !== count).map(([table, count]) => `${table}: expected ${count}, got ${actual[table] ?? 0}`);
|
|
3458
|
-
}
|
|
3459
|
-
function checkExpectation(fixture, result, declaration) {
|
|
3460
|
-
const expect = fixture.case.expect;
|
|
3461
|
-
if (!expect)
|
|
3462
|
-
return [];
|
|
3463
|
-
const findings = [];
|
|
3464
|
-
const failed = (what, problems, detail) => {
|
|
3465
|
-
findings.push({ code: "EXPECTATION_FAILED", message: `expect.${what} does not match (${problems.join("; ")})`, detail });
|
|
3466
|
-
};
|
|
3467
|
-
if (expect.records) {
|
|
3468
|
-
const actual = countBy(result.records);
|
|
3469
|
-
const problems = sameCounts(expect.records, actual);
|
|
3470
|
-
if (problems.length > 0)
|
|
3471
|
-
failed("records", problems, { expected: expect.records, actual });
|
|
3472
|
-
}
|
|
3473
|
-
if (expect.removals) {
|
|
3474
|
-
const actual = countBy(result.removals);
|
|
3475
|
-
const problems = sameCounts(expect.removals, actual);
|
|
3476
|
-
if (problems.length > 0)
|
|
3477
|
-
failed("removals", problems, { expected: expect.removals, actual });
|
|
3478
|
-
}
|
|
3479
|
-
if (expect.rejected !== undefined && expect.rejected !== result.rejected.length) {
|
|
3480
|
-
failed("rejected", [`expected ${expect.rejected}, got ${result.rejected.length}`], { expected: expect.rejected, actual: result.rejected.length });
|
|
3481
|
-
}
|
|
3482
|
-
if (expect.checkpoints !== undefined && expect.checkpoints !== result.checkpoints.length) {
|
|
3483
|
-
failed("checkpoints", [`expected ${expect.checkpoints}, got ${result.checkpoints.length}`], { expected: expect.checkpoints, actual: result.checkpoints.length });
|
|
3484
|
-
}
|
|
3485
|
-
if (expect.tables) {
|
|
3486
|
-
const actual = Object.keys(scopedTables(declaration, scopeOf(fixture.case)) ?? {}).sort();
|
|
3487
|
-
const expected = [...new Set(expect.tables)].sort();
|
|
3488
|
-
if (actual.length !== expected.length || actual.some((table, index) => table !== expected[index])) {
|
|
3489
|
-
failed("tables", [`expected [${expected.join(", ")}], got [${actual.join(", ")}]`], { expected, actual });
|
|
3490
|
-
}
|
|
3491
|
-
}
|
|
3492
|
-
return findings;
|
|
3493
|
-
}
|
|
3494
|
-
async function runFixture(executor, fixture, options = {}) {
|
|
3495
|
-
const fixtureCase = fixture.case;
|
|
3496
|
-
const config = fixtureCase.config;
|
|
3497
|
-
const secretFields = Object.keys(fixtureCase.secrets ?? {});
|
|
3498
|
-
const execute = async (job, limit) => {
|
|
3499
|
-
const host = createReplayHost(fixture);
|
|
3500
|
-
try {
|
|
3501
|
-
const result = await executor.run(job, host, {
|
|
3502
|
-
...limit !== undefined ? { limit } : {},
|
|
3503
|
-
...options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}
|
|
3504
|
-
});
|
|
3505
|
-
return { host, result };
|
|
3506
|
-
} finally {
|
|
3507
|
-
await host.close();
|
|
3508
|
-
}
|
|
3509
|
-
};
|
|
3510
|
-
let main;
|
|
3511
|
-
let declaration = null;
|
|
3512
|
-
let rerun;
|
|
3513
|
-
let error = null;
|
|
3514
|
-
switch (fixtureCase.kind) {
|
|
3515
|
-
case "pull": {
|
|
3516
|
-
const job = {
|
|
3517
|
-
kind: "pull",
|
|
3518
|
-
config,
|
|
3519
|
-
secretFields,
|
|
3520
|
-
stream: fixtureCase.stream,
|
|
3521
|
-
mode: fixtureCase.mode,
|
|
3522
|
-
state: fixtureCase.state ?? null,
|
|
3523
|
-
streamConfig: fixtureCase.streamConfig ?? {},
|
|
3524
|
-
tables: [],
|
|
3525
|
-
discoverFirst: true
|
|
3526
|
-
};
|
|
3527
|
-
main = await execute(job, fixtureCase.limit);
|
|
3528
|
-
declaration = main.result.declaration;
|
|
3529
|
-
if (fixtureCase.mode === "full" && main.result.error === null)
|
|
3530
|
-
rerun = (await execute(job, fixtureCase.limit)).result;
|
|
3531
|
-
break;
|
|
3532
|
-
}
|
|
3533
|
-
case "push": {
|
|
3534
|
-
const discovered = await execute({ kind: "discover", config, secretFields });
|
|
3535
|
-
if (discovered.result.error) {
|
|
3536
|
-
main = discovered;
|
|
3537
|
-
error = { ...discovered.result.error, message: `discover failed before replaying the push requests: ${discovered.result.error.message}` };
|
|
3538
|
-
break;
|
|
3539
|
-
}
|
|
3540
|
-
declaration = discovered.result.declaration;
|
|
3541
|
-
main = await execute({
|
|
3542
|
-
kind: "push",
|
|
3543
|
-
config,
|
|
3544
|
-
secretFields,
|
|
3545
|
-
requests: fixture.requests.map((request) => ({ ...request, headers: { ...request.headers }, push: fixtureCase.push }))
|
|
3546
|
-
});
|
|
3547
|
-
break;
|
|
3548
|
-
}
|
|
3549
|
-
case "discover":
|
|
3550
|
-
main = await execute({ kind: "discover", config, secretFields });
|
|
3551
|
-
declaration = main.result.declaration;
|
|
3552
|
-
break;
|
|
3553
|
-
}
|
|
3554
|
-
const result = main.result;
|
|
3555
|
-
error ??= result.error;
|
|
3556
|
-
const findings = [];
|
|
3557
|
-
for (const item of main.host.mismatches()) {
|
|
3558
|
-
findings.push({ code: "FIXTURE_EXCHANGE_MISMATCH", message: `no recorded exchange matches ${item.method} ${item.detail}`, detail: item });
|
|
3559
|
-
}
|
|
3560
|
-
if (error === null) {
|
|
3561
|
-
for (const exchange of main.host.unused()) {
|
|
3562
|
-
findings.push({
|
|
3563
|
-
code: "FIXTURE_EXCHANGE_UNUSED",
|
|
3564
|
-
message: `recorded exchange ${exchange.seq} (${exchange.kind} ${exchangeSummary(exchange)}) was not used`,
|
|
3565
|
-
detail: { seq: exchange.seq, kind: exchange.kind }
|
|
3566
|
-
});
|
|
3567
|
-
}
|
|
3568
|
-
findings.push(...checkCompliance({
|
|
3569
|
-
declaration,
|
|
3570
|
-
...scopeOf(fixtureCase),
|
|
3571
|
-
...fixtureCase.kind === "pull" ? { mode: fixtureCase.mode } : {},
|
|
3572
|
-
result,
|
|
3573
|
-
...rerun ? { rerun } : {}
|
|
3574
|
-
}));
|
|
3575
|
-
findings.push(...checkExpectation(fixture, result, declaration));
|
|
3576
|
-
}
|
|
3577
|
-
return {
|
|
3578
|
-
name: fixture.name,
|
|
3579
|
-
ok: error === null && findings.every((finding) => finding.code === "FIXTURE_EXCHANGE_UNUSED"),
|
|
3580
|
-
findings,
|
|
3581
|
-
stats: { records: countBy(result.records), removals: countBy(result.removals), checkpoints: result.checkpoints.length, rejected: result.rejected.length },
|
|
3582
|
-
declaration: toDeclaration(declaration),
|
|
3583
|
-
error
|
|
3584
|
-
};
|
|
3585
|
-
}
|
|
3586
|
-
async function runFixtures(executor, files, options = {}) {
|
|
3587
|
-
const results = [];
|
|
3588
|
-
for (const fixture of parseFixtures(files))
|
|
3589
|
-
results.push(await runFixture(executor, fixture, options));
|
|
3590
|
-
return results;
|
|
3591
|
-
}
|