@unito/integration-cli 0.64.6 → 0.66.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/dist/boilerplate/package-lock.json +11 -24
- package/dist/src/commands/schema-snapshot.d.ts +9 -0
- package/dist/src/commands/schema-snapshot.js +432 -0
- package/dist/src/resources/coverageCalculator.d.ts +12 -0
- package/dist/src/resources/coverageCalculator.js +50 -0
- package/dist/src/resources/fieldExtractor.d.ts +7 -0
- package/dist/src/resources/fieldExtractor.js +45 -0
- package/dist/src/resources/jsonlReader.d.ts +11 -0
- package/dist/src/resources/jsonlReader.js +29 -0
- package/dist/test/resources/coverageCalculator.test.d.ts +1 -0
- package/dist/test/resources/coverageCalculator.test.js +71 -0
- package/dist/test/resources/fieldExtractor.test.d.ts +1 -0
- package/dist/test/resources/fieldExtractor.test.js +60 -0
- package/dist/test/resources/jsonlReader.test.d.ts +1 -0
- package/dist/test/resources/jsonlReader.test.js +66 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/oclif.manifest.json +97 -1
- package/package.json +2 -2
|
@@ -866,37 +866,24 @@
|
|
|
866
866
|
"typescript": ">=4.8.4 <6.0.0"
|
|
867
867
|
}
|
|
868
868
|
},
|
|
869
|
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
870
|
-
"version": "4.0.4",
|
|
871
|
-
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
872
|
-
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
873
|
-
"dev": true,
|
|
874
|
-
"license": "MIT",
|
|
875
|
-
"engines": {
|
|
876
|
-
"node": "18 || 20 || >=22"
|
|
877
|
-
}
|
|
878
|
-
},
|
|
879
869
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
880
|
-
"version": "
|
|
881
|
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-
|
|
882
|
-
"integrity": "sha512-
|
|
870
|
+
"version": "2.0.2",
|
|
871
|
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
|
872
|
+
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
|
883
873
|
"dev": true,
|
|
884
874
|
"license": "MIT",
|
|
885
875
|
"dependencies": {
|
|
886
|
-
"balanced-match": "^
|
|
887
|
-
},
|
|
888
|
-
"engines": {
|
|
889
|
-
"node": "18 || 20 || >=22"
|
|
876
|
+
"balanced-match": "^1.0.0"
|
|
890
877
|
}
|
|
891
878
|
},
|
|
892
879
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
|
893
|
-
"version": "9.0.
|
|
894
|
-
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.
|
|
895
|
-
"integrity": "sha512-
|
|
880
|
+
"version": "9.0.9",
|
|
881
|
+
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
|
882
|
+
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
|
896
883
|
"dev": true,
|
|
897
884
|
"license": "ISC",
|
|
898
885
|
"dependencies": {
|
|
899
|
-
"brace-expansion": "^
|
|
886
|
+
"brace-expansion": "^2.0.2"
|
|
900
887
|
},
|
|
901
888
|
"engines": {
|
|
902
889
|
"node": ">=16 || 14 >=14.17"
|
|
@@ -2294,9 +2281,9 @@
|
|
|
2294
2281
|
}
|
|
2295
2282
|
},
|
|
2296
2283
|
"node_modules/minimatch": {
|
|
2297
|
-
"version": "3.1.
|
|
2298
|
-
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.
|
|
2299
|
-
"integrity": "sha512-
|
|
2284
|
+
"version": "3.1.5",
|
|
2285
|
+
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
|
2286
|
+
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
|
2300
2287
|
"dev": true,
|
|
2301
2288
|
"license": "ISC",
|
|
2302
2289
|
"dependencies": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { FlagInput } from '@oclif/core/lib/interfaces/parser';
|
|
2
|
+
import { BaseCommand } from '../baseCommand';
|
|
3
|
+
export default class SchemaSnapshot extends BaseCommand<typeof SchemaSnapshot> {
|
|
4
|
+
static description: string;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: FlagInput;
|
|
7
|
+
catch(error: Error): Promise<void>;
|
|
8
|
+
run(): Promise<void>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const child_process_1 = tslib_1.__importDefault(require("child_process"));
|
|
5
|
+
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
6
|
+
const os_1 = tslib_1.__importDefault(require("os"));
|
|
7
|
+
const path_1 = tslib_1.__importDefault(require("path"));
|
|
8
|
+
const core_1 = require("@oclif/core");
|
|
9
|
+
const chalk_1 = tslib_1.__importDefault(require("chalk"));
|
|
10
|
+
const baseCommand_1 = require("../baseCommand");
|
|
11
|
+
const errors_1 = require("../errors");
|
|
12
|
+
const configuration_1 = require("../resources/configuration");
|
|
13
|
+
const coverageCalculator_1 = require("../resources/coverageCalculator");
|
|
14
|
+
const decryption_1 = require("../resources/decryption");
|
|
15
|
+
const fieldExtractor_1 = require("../resources/fieldExtractor");
|
|
16
|
+
const GlobalConfiguration = tslib_1.__importStar(require("../resources/globalConfiguration"));
|
|
17
|
+
const integrations_1 = require("../resources/integrations");
|
|
18
|
+
const jsonlReader_1 = require("../resources/jsonlReader");
|
|
19
|
+
class SchemaSnapshot extends baseCommand_1.BaseCommand {
|
|
20
|
+
static description = 'Generate a schema snapshot by crawling the integration graph';
|
|
21
|
+
static examples = [
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json',
|
|
23
|
+
'<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json --diff=./previous-snapshot.json',
|
|
24
|
+
];
|
|
25
|
+
static flags = {
|
|
26
|
+
output: core_1.Flags.string({
|
|
27
|
+
description: 'path to write the schema snapshot',
|
|
28
|
+
required: true,
|
|
29
|
+
}),
|
|
30
|
+
diff: core_1.Flags.string({
|
|
31
|
+
description: 'path to a previous snapshot for comparison',
|
|
32
|
+
}),
|
|
33
|
+
environment: core_1.Flags.custom({
|
|
34
|
+
description: 'the environment of the platform',
|
|
35
|
+
options: Object.values(GlobalConfiguration.Environment),
|
|
36
|
+
default: GlobalConfiguration.Environment.Production,
|
|
37
|
+
})(),
|
|
38
|
+
'test-account': core_1.Flags.string({
|
|
39
|
+
description: 'test account to use',
|
|
40
|
+
default: configuration_1.CredentialScope.DEVELOPMENT,
|
|
41
|
+
}),
|
|
42
|
+
'config-path': core_1.Flags.string({
|
|
43
|
+
description: 'relative path to a custom ".unito.json" file',
|
|
44
|
+
}),
|
|
45
|
+
'skip-install': core_1.Flags.boolean({
|
|
46
|
+
description: 'skip npm install before starting',
|
|
47
|
+
default: false,
|
|
48
|
+
}),
|
|
49
|
+
strict: core_1.Flags.boolean({
|
|
50
|
+
description: 'fail if any fields are unmapped and not in the allowlist',
|
|
51
|
+
default: false,
|
|
52
|
+
}),
|
|
53
|
+
port: core_1.Flags.integer({
|
|
54
|
+
description: 'port to start the integration on (default: 9200)',
|
|
55
|
+
default: 9200,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
async catch(error) {
|
|
59
|
+
if ((0, errors_1.handleError)(this, error)) {
|
|
60
|
+
this.exit(-1);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
async run() {
|
|
65
|
+
(0, integrations_1.validateIsIntegrationDirectory)();
|
|
66
|
+
const { flags } = await this.parse(SchemaSnapshot);
|
|
67
|
+
const environment = flags.environment ?? GlobalConfiguration.Environment.Production;
|
|
68
|
+
const config = await (0, configuration_1.getConfiguration)(environment, flags['config-path']);
|
|
69
|
+
if (!flags['skip-install']) {
|
|
70
|
+
core_1.ux.action.start('Installing NPM dependencies', undefined, { stdout: true });
|
|
71
|
+
child_process_1.default.execSync('npm install', {
|
|
72
|
+
env: { ...process.env, NODE_ENV: 'development' },
|
|
73
|
+
stdio: ['ignore', 'ignore', 'inherit'],
|
|
74
|
+
});
|
|
75
|
+
core_1.ux.action.stop();
|
|
76
|
+
}
|
|
77
|
+
// Resolve credentials.
|
|
78
|
+
const testAccount = flags['test-account'];
|
|
79
|
+
const credentials = config.testAccounts?.[testAccount] || {};
|
|
80
|
+
const decryptedCreds = await (0, decryption_1.decryptEntries)(config.name, environment, this.config.configDir, credentials);
|
|
81
|
+
if (decryptedCreds.failed.length) {
|
|
82
|
+
throw new errors_1.EntryDecryptionError(decryptedCreds.failed.at(0), environment);
|
|
83
|
+
}
|
|
84
|
+
const credentialPayload = {
|
|
85
|
+
...decryptedCreds.successful,
|
|
86
|
+
unitoCredentialId: flags['test-account'],
|
|
87
|
+
unitoUserId: flags['test-account'],
|
|
88
|
+
};
|
|
89
|
+
// Load secrets.
|
|
90
|
+
const { successful: secrets, failed: failedSecrets } = await (0, decryption_1.decryptEntries)(config.name, environment, this.config.configDir, config.secrets ?? {});
|
|
91
|
+
if (failedSecrets.length) {
|
|
92
|
+
throw new errors_1.EntryDecryptionError(failedSecrets.at(0), environment);
|
|
93
|
+
}
|
|
94
|
+
core_1.ux.action.start('Starting integration', undefined, { stdout: true });
|
|
95
|
+
const integrationPort = String(flags.port);
|
|
96
|
+
let modernProcess;
|
|
97
|
+
const recordingPath = path_1.default.join(os_1.default.tmpdir(), `schema-snapshot-recording-${Date.now()}.jsonl`);
|
|
98
|
+
// Pre-create recording file with restrictive permissions (owner-only read/write).
|
|
99
|
+
fs_1.default.writeFileSync(recordingPath, '', { mode: 0o600 });
|
|
100
|
+
try {
|
|
101
|
+
modernProcess = child_process_1.default.spawn('npm', ['run', 'dev'], {
|
|
102
|
+
detached: false,
|
|
103
|
+
stdio: 'ignore',
|
|
104
|
+
env: {
|
|
105
|
+
...process.env,
|
|
106
|
+
NODE_ENV: 'development',
|
|
107
|
+
PORT: integrationPort,
|
|
108
|
+
UNITO_SCHEMA_SNAPSHOT_RECORD_PATH: recordingPath,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
112
|
+
core_1.ux.action.stop();
|
|
113
|
+
// Load allowlist if present.
|
|
114
|
+
const allowlistPath = path_1.default.join(process.cwd(), '.schema-snapshot-ignore');
|
|
115
|
+
let allowlistConfig = {};
|
|
116
|
+
if (fs_1.default.existsSync(allowlistPath)) {
|
|
117
|
+
try {
|
|
118
|
+
allowlistConfig = JSON.parse(fs_1.default.readFileSync(allowlistPath, 'utf-8'));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
core_1.ux.log(chalk_1.default.yellow(`Warning: Failed to parse ${allowlistPath}, ignoring allowlist. ${err}`));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Create crawler in sample mode (read-only, limited items).
|
|
125
|
+
const { createWithDirectCrawler, Operation } = await Promise.resolve().then(() => tslib_1.__importStar(require('@unito/integration-debugger/dist/src/services/crawlerDriver')));
|
|
126
|
+
const crawlerDriver = await createWithDirectCrawler(`http://localhost:${integrationPort}`, config.graphRelativeUrl ?? '/', config.credentialAccountRelativeUrl ?? '/me', config.webhookParsingRelativeUrl ?? undefined, config.webhookSubscriptionsRelativeUrl ?? undefined, config.webhookAcknowledgeRelativeUrl ?? undefined, credentialPayload, secrets, {
|
|
127
|
+
readOnly: true,
|
|
128
|
+
timeout: 20,
|
|
129
|
+
[Operation.GetCollection]: {
|
|
130
|
+
itemsPerPage: 5,
|
|
131
|
+
followNextPage: false,
|
|
132
|
+
},
|
|
133
|
+
retryOnEmptyRelation: {
|
|
134
|
+
maxSiblings: 3,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
// Only crawl graph structure, skip all step checks.
|
|
138
|
+
crawlerDriver.stepCheckKeys = [];
|
|
139
|
+
const credentialAccountPath = config.credentialAccountRelativeUrl ?? '/me';
|
|
140
|
+
crawlerDriver.startFrom(makeCrawlerStep(credentialAccountPath, Operation.GetCredentialAccount));
|
|
141
|
+
core_1.ux.action.start('Crawling credential account', undefined, { stdout: true });
|
|
142
|
+
await crawlerDriver.next();
|
|
143
|
+
core_1.ux.action.stop();
|
|
144
|
+
const startingPath = config.graphRelativeUrl ?? '/';
|
|
145
|
+
crawlerDriver.startFrom(makeCrawlerStep(startingPath, Operation.GetItem));
|
|
146
|
+
core_1.ux.action.start('Crawling graph for schemas', undefined, { stdout: true });
|
|
147
|
+
const resources = [];
|
|
148
|
+
let step = await crawlerDriver.next();
|
|
149
|
+
let lastSeq = 0;
|
|
150
|
+
// Raw API responses keyed by schemaPath (the relation collection path they belong to).
|
|
151
|
+
const rawResponsesBySchemaPath = new Map();
|
|
152
|
+
while (step) {
|
|
153
|
+
const payload = step.payloadOut;
|
|
154
|
+
if (step.operation === Operation.GetItem && payload?.relations) {
|
|
155
|
+
const relations = payload.relations;
|
|
156
|
+
const relationSnapshots = relations.map(rel => ({
|
|
157
|
+
name: rel.name,
|
|
158
|
+
path: rel.path,
|
|
159
|
+
label: rel.label,
|
|
160
|
+
semantic: rel.semantic,
|
|
161
|
+
canCreateItem: rel.schema.canCreateItem,
|
|
162
|
+
fields: normalizeFields(rel.schema.fields ?? []),
|
|
163
|
+
}));
|
|
164
|
+
relationSnapshots.sort((a, b) => a.name.localeCompare(b.name));
|
|
165
|
+
resources.push({
|
|
166
|
+
path: step.path,
|
|
167
|
+
operation: step.operation,
|
|
168
|
+
schemaPath: step.schemaPath,
|
|
169
|
+
relations: relationSnapshots,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// Read raw API responses recorded during this step.
|
|
173
|
+
const { entries: newEntries, lastSeq: newLastSeq } = (0, jsonlReader_1.readNewEntries)(recordingPath, lastSeq);
|
|
174
|
+
lastSeq = newLastSeq;
|
|
175
|
+
// Associate raw responses with their schemaPath (the relation's collection path).
|
|
176
|
+
// This links item-level API calls (e.g. /pokemons/bulbasaur) back to the relation
|
|
177
|
+
// schema defined on the parent resource (e.g. the "pokemons" relation at /).
|
|
178
|
+
if (step.operation === Operation.GetItem && step.schemaPath && newEntries.length > 0) {
|
|
179
|
+
const existing = rawResponsesBySchemaPath.get(step.schemaPath) ?? [];
|
|
180
|
+
existing.push(...newEntries.map(e => ({ url: e.url, body: e.body })));
|
|
181
|
+
rawResponsesBySchemaPath.set(step.schemaPath, existing);
|
|
182
|
+
}
|
|
183
|
+
step = await crawlerDriver.next();
|
|
184
|
+
}
|
|
185
|
+
core_1.ux.action.stop();
|
|
186
|
+
// Sort resources by path for deterministic output.
|
|
187
|
+
resources.sort((a, b) => a.path.localeCompare(b.path));
|
|
188
|
+
// Compute coverage for each relation using raw API responses.
|
|
189
|
+
// Raw responses are keyed by schemaPath — each relation's `path` is the schemaPath
|
|
190
|
+
// for items crawled under that relation.
|
|
191
|
+
for (const resource of resources) {
|
|
192
|
+
for (const relation of resource.relations) {
|
|
193
|
+
const rawResponses = rawResponsesBySchemaPath.get(relation.path) ?? [];
|
|
194
|
+
if (rawResponses.length === 0)
|
|
195
|
+
continue;
|
|
196
|
+
// Combine fields from all raw API responses for this relation.
|
|
197
|
+
const allRawFields = new Set();
|
|
198
|
+
const endpoints = [];
|
|
199
|
+
for (const resp of rawResponses) {
|
|
200
|
+
for (const field of (0, fieldExtractor_1.extractFields)(resp.body)) {
|
|
201
|
+
allRawFields.add(field);
|
|
202
|
+
}
|
|
203
|
+
endpoints.push(resp.url);
|
|
204
|
+
}
|
|
205
|
+
const key = relationKey(resource.path, relation.name);
|
|
206
|
+
const allowlist = allowlistConfig[key]?.unmapped ?? [];
|
|
207
|
+
const relFieldNames = relation.fields.map(f => f.name);
|
|
208
|
+
const rawFieldsArray = [...allRawFields];
|
|
209
|
+
const coverageResult = (0, coverageCalculator_1.computeCoverage)(rawFieldsArray, relFieldNames);
|
|
210
|
+
// Apply allowlist to filter intentionally unmapped fields.
|
|
211
|
+
const filteredUnmapped = (0, coverageCalculator_1.applyAllowlist)(coverageResult.unmappedFields, allowlist);
|
|
212
|
+
// Recalculate coverage after allowlist.
|
|
213
|
+
const effectiveMapped = rawFieldsArray.length - filteredUnmapped.length;
|
|
214
|
+
const effectiveCoveragePercent = rawFieldsArray.length > 0 ? Math.round((effectiveMapped / rawFieldsArray.length) * 100) : 100;
|
|
215
|
+
relation.coverage = {
|
|
216
|
+
rawApiFields: coverageResult.rawApiFields,
|
|
217
|
+
mappedFields: coverageResult.mappedFields,
|
|
218
|
+
unmappedFields: filteredUnmapped,
|
|
219
|
+
coveragePercent: effectiveCoveragePercent,
|
|
220
|
+
rawApiEndpoints: [...new Set(endpoints)],
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const snapshot = {
|
|
225
|
+
generatedAt: new Date().toISOString(),
|
|
226
|
+
resources,
|
|
227
|
+
};
|
|
228
|
+
fs_1.default.writeFileSync(flags.output, JSON.stringify(snapshot, null, 2));
|
|
229
|
+
core_1.ux.log(chalk_1.default.green(`\nSnapshot written to ${flags.output}`));
|
|
230
|
+
core_1.ux.log(chalk_1.default.gray(` ${resources.length} resource(s), ${resources.reduce((sum, r) => sum + r.relations.length, 0)} relation(s)`));
|
|
231
|
+
// Display coverage summary.
|
|
232
|
+
const relationsWithCoverage = resources.flatMap(r => r.relations).filter(rel => rel.coverage);
|
|
233
|
+
if (relationsWithCoverage.length > 0) {
|
|
234
|
+
core_1.ux.log(chalk_1.default.bold('\nCoverage Summary'));
|
|
235
|
+
core_1.ux.log(chalk_1.default.bold('================\n'));
|
|
236
|
+
for (const rel of relationsWithCoverage) {
|
|
237
|
+
const cov = rel.coverage;
|
|
238
|
+
const color = coverageColor(cov.coveragePercent);
|
|
239
|
+
core_1.ux.log(` ${rel.name}: ${color(`${cov.coveragePercent}%`)} (${cov.mappedFields.length}/${cov.rawApiFields.length} fields)`);
|
|
240
|
+
if (cov.unmappedFields.length > 0) {
|
|
241
|
+
core_1.ux.log(chalk_1.default.gray(` unmapped: ${cov.unmappedFields.slice(0, 10).join(', ')}${cov.unmappedFields.length > 10 ? ` (+${cov.unmappedFields.length - 10} more)` : ''}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Strict mode: fail if any unmapped fields remain after allowlist.
|
|
246
|
+
if (flags.strict) {
|
|
247
|
+
const failures = relationsWithCoverage.filter(rel => rel.coverage.unmappedFields.length > 0);
|
|
248
|
+
if (failures.length > 0) {
|
|
249
|
+
core_1.ux.log(chalk_1.default.red('\n--strict: unmapped fields detected\n'));
|
|
250
|
+
for (const rel of failures) {
|
|
251
|
+
core_1.ux.log(chalk_1.default.red(` ${rel.name}: ${rel.coverage.unmappedFields.length} unmapped fields`));
|
|
252
|
+
}
|
|
253
|
+
core_1.ux.log(chalk_1.default.yellow('\nAdd missing fields to the schema or justify them in .schema-snapshot-ignore'));
|
|
254
|
+
this.exit(1);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Diff against previous snapshot if requested.
|
|
258
|
+
if (flags.diff) {
|
|
259
|
+
const diffPath = flags.diff;
|
|
260
|
+
if (!fs_1.default.existsSync(diffPath)) {
|
|
261
|
+
core_1.ux.log(chalk_1.default.yellow(`\nDiff file not found: ${diffPath}`));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const previousRaw = fs_1.default.readFileSync(diffPath, 'utf-8');
|
|
265
|
+
const previous = JSON.parse(previousRaw);
|
|
266
|
+
const diff = diffSnapshots(previous, snapshot);
|
|
267
|
+
core_1.ux.log(chalk_1.default.bold('\nSchema Diff'));
|
|
268
|
+
core_1.ux.log(chalk_1.default.bold('===========\n'));
|
|
269
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
|
|
270
|
+
core_1.ux.log(chalk_1.default.green('No differences found.'));
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
for (const added of diff.added) {
|
|
274
|
+
core_1.ux.log(chalk_1.default.green(` + ${added}`));
|
|
275
|
+
}
|
|
276
|
+
for (const removed of diff.removed) {
|
|
277
|
+
core_1.ux.log(chalk_1.default.red(` - ${removed}`));
|
|
278
|
+
}
|
|
279
|
+
for (const changed of diff.changed) {
|
|
280
|
+
core_1.ux.log(chalk_1.default.yellow(` ~ ${changed.path}: ${changed.details}`));
|
|
281
|
+
}
|
|
282
|
+
core_1.ux.log(`\n ${chalk_1.default.green(`${diff.added.length} added`)}, ${chalk_1.default.red(`${diff.removed.length} removed`)}, ${chalk_1.default.yellow(`${diff.changed.length} changed`)}`);
|
|
283
|
+
}
|
|
284
|
+
if (diff.coverageChanges.length > 0) {
|
|
285
|
+
core_1.ux.log(chalk_1.default.bold('\nCoverage Changes'));
|
|
286
|
+
core_1.ux.log(chalk_1.default.bold('================\n'));
|
|
287
|
+
for (const change of diff.coverageChanges) {
|
|
288
|
+
const color = change.details.startsWith('↓') ? chalk_1.default.red : chalk_1.default.green;
|
|
289
|
+
core_1.ux.log(color(` ${change.path}: ${change.details}`));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
modernProcess?.kill('SIGTERM');
|
|
297
|
+
try {
|
|
298
|
+
fs_1.default.unlinkSync(recordingPath);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
/* Best-effort cleanup. */
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
exports.default = SchemaSnapshot;
|
|
307
|
+
function relationKey(resourcePath, relationName) {
|
|
308
|
+
return `${resourcePath} → ${relationName}`;
|
|
309
|
+
}
|
|
310
|
+
function makeCrawlerStep(stepPath, operation) {
|
|
311
|
+
return {
|
|
312
|
+
path: stepPath,
|
|
313
|
+
operation,
|
|
314
|
+
schemaPath: undefined,
|
|
315
|
+
parentOperation: undefined,
|
|
316
|
+
parentPath: undefined,
|
|
317
|
+
requestSchema: undefined,
|
|
318
|
+
warnings: [],
|
|
319
|
+
errors: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function coverageColor(percent) {
|
|
323
|
+
if (percent >= 80)
|
|
324
|
+
return chalk_1.default.green;
|
|
325
|
+
if (percent >= 50)
|
|
326
|
+
return chalk_1.default.yellow;
|
|
327
|
+
return chalk_1.default.red;
|
|
328
|
+
}
|
|
329
|
+
const OPTIONAL_FIELD_KEYS = [
|
|
330
|
+
'semantic',
|
|
331
|
+
'nullable',
|
|
332
|
+
'isArray',
|
|
333
|
+
'canSetOnCreate',
|
|
334
|
+
'canSetOnUpdate',
|
|
335
|
+
'readOnly',
|
|
336
|
+
];
|
|
337
|
+
function normalizeFields(fields) {
|
|
338
|
+
return fields
|
|
339
|
+
.map(f => {
|
|
340
|
+
const snapshot = {
|
|
341
|
+
name: f.name,
|
|
342
|
+
type: f.type,
|
|
343
|
+
};
|
|
344
|
+
for (const key of OPTIONAL_FIELD_KEYS) {
|
|
345
|
+
if (f[key] !== undefined) {
|
|
346
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
347
|
+
snapshot[key] = f[key];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return snapshot;
|
|
351
|
+
})
|
|
352
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
353
|
+
}
|
|
354
|
+
function diffSnapshots(previous, current) {
|
|
355
|
+
const result = { added: [], removed: [], changed: [], coverageChanges: [] };
|
|
356
|
+
const prevMap = buildSchemaMap(previous);
|
|
357
|
+
const currMap = buildSchemaMap(current);
|
|
358
|
+
const allKeys = new Set([...prevMap.keys(), ...currMap.keys()]);
|
|
359
|
+
for (const key of allKeys) {
|
|
360
|
+
const prev = prevMap.get(key);
|
|
361
|
+
const curr = currMap.get(key);
|
|
362
|
+
if (!prev && curr) {
|
|
363
|
+
result.added.push(key);
|
|
364
|
+
}
|
|
365
|
+
else if (prev && !curr) {
|
|
366
|
+
result.removed.push(key);
|
|
367
|
+
}
|
|
368
|
+
else if (prev && curr) {
|
|
369
|
+
const prevFields = new Set(prev);
|
|
370
|
+
const currFields = new Set(curr);
|
|
371
|
+
const addedFields = [...currFields].filter(f => !prevFields.has(f));
|
|
372
|
+
const removedFields = [...prevFields].filter(f => !currFields.has(f));
|
|
373
|
+
if (addedFields.length > 0 || removedFields.length > 0) {
|
|
374
|
+
const details = [];
|
|
375
|
+
if (addedFields.length > 0)
|
|
376
|
+
details.push(`+fields: ${addedFields.join(', ')}`);
|
|
377
|
+
if (removedFields.length > 0)
|
|
378
|
+
details.push(`-fields: ${removedFields.join(', ')}`);
|
|
379
|
+
result.changed.push({ path: key, details: details.join('; ') });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Coverage changes.
|
|
384
|
+
const prevCoverageMap = buildCoverageMap(previous);
|
|
385
|
+
const currCoverageMap = buildCoverageMap(current);
|
|
386
|
+
const allCoverageKeys = new Set([...prevCoverageMap.keys(), ...currCoverageMap.keys()]);
|
|
387
|
+
for (const key of allCoverageKeys) {
|
|
388
|
+
const prev = prevCoverageMap.get(key);
|
|
389
|
+
const curr = currCoverageMap.get(key);
|
|
390
|
+
if (prev && curr && prev.coveragePercent !== curr.coveragePercent) {
|
|
391
|
+
const direction = curr.coveragePercent > prev.coveragePercent ? '↑' : '↓';
|
|
392
|
+
const newUnmapped = curr.unmappedFields.filter(f => !prev.unmappedFields.includes(f));
|
|
393
|
+
const newlyMapped = prev.unmappedFields.filter(f => !curr.unmappedFields.includes(f));
|
|
394
|
+
let details = `${direction} coverage ${prev.coveragePercent}% → ${curr.coveragePercent}%`;
|
|
395
|
+
const annotations = [];
|
|
396
|
+
if (newUnmapped.length > 0)
|
|
397
|
+
annotations.push(`new unmapped: ${newUnmapped.join(', ')}`);
|
|
398
|
+
if (newlyMapped.length > 0)
|
|
399
|
+
annotations.push(`newly mapped: ${newlyMapped.join(', ')}`);
|
|
400
|
+
if (annotations.length > 0)
|
|
401
|
+
details += ` (${annotations.join(', ')})`;
|
|
402
|
+
result.coverageChanges.push({ path: key, details });
|
|
403
|
+
}
|
|
404
|
+
else if (!prev && curr) {
|
|
405
|
+
result.coverageChanges.push({
|
|
406
|
+
path: key,
|
|
407
|
+
details: `new coverage data: ${curr.coveragePercent}% (${curr.unmappedFields.length} unmapped)`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
}
|
|
413
|
+
function buildCoverageMap(snapshot) {
|
|
414
|
+
const map = new Map();
|
|
415
|
+
for (const resource of snapshot.resources) {
|
|
416
|
+
for (const relation of resource.relations) {
|
|
417
|
+
if (relation.coverage) {
|
|
418
|
+
map.set(relationKey(resource.path, relation.name), relation.coverage);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return map;
|
|
423
|
+
}
|
|
424
|
+
function buildSchemaMap(snapshot) {
|
|
425
|
+
const map = new Map();
|
|
426
|
+
for (const resource of snapshot.resources) {
|
|
427
|
+
for (const relation of resource.relations) {
|
|
428
|
+
map.set(relationKey(resource.path, relation.name), relation.fields.map(f => f.name));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return map;
|
|
432
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface CoverageResult {
|
|
2
|
+
rawApiFields: string[];
|
|
3
|
+
mappedFields: string[];
|
|
4
|
+
unmappedFields: string[];
|
|
5
|
+
coveragePercent: number;
|
|
6
|
+
}
|
|
7
|
+
export interface AllowlistEntry {
|
|
8
|
+
field: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function computeCoverage(rawApiFields: string[], schemaFields: string[]): CoverageResult;
|
|
12
|
+
export declare function applyAllowlist(unmappedFields: string[], allowlist: AllowlistEntry[]): string[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeCoverage = computeCoverage;
|
|
4
|
+
exports.applyAllowlist = applyAllowlist;
|
|
5
|
+
function computeCoverage(rawApiFields, schemaFields) {
|
|
6
|
+
if (rawApiFields.length === 0) {
|
|
7
|
+
return { rawApiFields: [], mappedFields: [], unmappedFields: [], coveragePercent: 100 };
|
|
8
|
+
}
|
|
9
|
+
const schemaSet = new Set(schemaFields);
|
|
10
|
+
const mapped = [];
|
|
11
|
+
const unmapped = [];
|
|
12
|
+
for (const field of [...rawApiFields].sort()) {
|
|
13
|
+
if (schemaSet.has(field)) {
|
|
14
|
+
mapped.push(field);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
unmapped.push(field);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const coveragePercent = Math.round((mapped.length / rawApiFields.length) * 100);
|
|
21
|
+
return {
|
|
22
|
+
rawApiFields: [...rawApiFields].sort(),
|
|
23
|
+
mappedFields: mapped,
|
|
24
|
+
unmappedFields: unmapped,
|
|
25
|
+
coveragePercent,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function applyAllowlist(unmappedFields, allowlist) {
|
|
29
|
+
if (allowlist.length === 0)
|
|
30
|
+
return [...unmappedFields].sort();
|
|
31
|
+
return unmappedFields
|
|
32
|
+
.filter(field => {
|
|
33
|
+
return !allowlist.some(entry => matchPattern(field, entry.field));
|
|
34
|
+
})
|
|
35
|
+
.sort();
|
|
36
|
+
}
|
|
37
|
+
// Matches dot-notation field paths against allowlist patterns:
|
|
38
|
+
// "priority" → exact match
|
|
39
|
+
// "metadata.*" → matches one level (metadata.created_at, not metadata.a.b)
|
|
40
|
+
// "meta.**" → matches any depth (meta.a, meta.a.b, meta.a.b.c)
|
|
41
|
+
function matchPattern(field, pattern) {
|
|
42
|
+
if (pattern.endsWith('.**')) {
|
|
43
|
+
return field.startsWith(pattern.slice(0, -2));
|
|
44
|
+
}
|
|
45
|
+
if (pattern.endsWith('.*')) {
|
|
46
|
+
const prefix = pattern.slice(0, -1);
|
|
47
|
+
return field.startsWith(prefix) && !field.slice(prefix.length).includes('.');
|
|
48
|
+
}
|
|
49
|
+
return field === pattern;
|
|
50
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively extract field paths from a JSON body using dot notation.
|
|
3
|
+
* Arrays of objects: extract keys from first element.
|
|
4
|
+
* Top-level arrays: extract from first item.
|
|
5
|
+
* Returns sorted, unique field paths.
|
|
6
|
+
*/
|
|
7
|
+
export declare function extractFields(body: unknown): string[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractFields = extractFields;
|
|
4
|
+
/**
|
|
5
|
+
* Recursively extract field paths from a JSON body using dot notation.
|
|
6
|
+
* Arrays of objects: extract keys from first element.
|
|
7
|
+
* Top-level arrays: extract from first item.
|
|
8
|
+
* Returns sorted, unique field paths.
|
|
9
|
+
*/
|
|
10
|
+
function extractFields(body) {
|
|
11
|
+
if (body == null)
|
|
12
|
+
return [];
|
|
13
|
+
if (Array.isArray(body)) {
|
|
14
|
+
if (body.length === 0)
|
|
15
|
+
return [];
|
|
16
|
+
if (typeof body[0] === 'object' && body[0] !== null) {
|
|
17
|
+
return extractFields(body[0]);
|
|
18
|
+
}
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
if (typeof body !== 'object')
|
|
22
|
+
return [];
|
|
23
|
+
const fields = new Set();
|
|
24
|
+
collectFields(body, '', fields);
|
|
25
|
+
return [...fields].sort();
|
|
26
|
+
}
|
|
27
|
+
function collectFields(obj, prefix, fields) {
|
|
28
|
+
for (const key of Object.keys(obj)) {
|
|
29
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
30
|
+
const value = obj[key];
|
|
31
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
32
|
+
fields.add(path);
|
|
33
|
+
collectFields(value, path, fields);
|
|
34
|
+
}
|
|
35
|
+
else if (Array.isArray(value)) {
|
|
36
|
+
fields.add(path);
|
|
37
|
+
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) {
|
|
38
|
+
collectFields(value[0], path, fields);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
fields.add(path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RawResponseEntry {
|
|
2
|
+
seq: number;
|
|
3
|
+
url: string;
|
|
4
|
+
method: string;
|
|
5
|
+
status: number;
|
|
6
|
+
body: unknown;
|
|
7
|
+
}
|
|
8
|
+
export declare function readNewEntries(filePath: string, afterSeq: number): {
|
|
9
|
+
entries: RawResponseEntry[];
|
|
10
|
+
lastSeq: number;
|
|
11
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readNewEntries = readNewEntries;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
6
|
+
function readNewEntries(filePath, afterSeq) {
|
|
7
|
+
if (!fs_1.default.existsSync(filePath)) {
|
|
8
|
+
return { entries: [], lastSeq: afterSeq };
|
|
9
|
+
}
|
|
10
|
+
const content = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
11
|
+
const lines = content.split('\n').filter(line => line.trim().length > 0);
|
|
12
|
+
const entries = [];
|
|
13
|
+
let lastSeq = afterSeq;
|
|
14
|
+
for (const line of lines) {
|
|
15
|
+
try {
|
|
16
|
+
const entry = JSON.parse(line);
|
|
17
|
+
if (entry.seq > afterSeq) {
|
|
18
|
+
entries.push(entry);
|
|
19
|
+
if (entry.seq > lastSeq) {
|
|
20
|
+
lastSeq = entry.seq;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Skip malformed lines.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { entries, lastSeq };
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const strict_1 = tslib_1.__importDefault(require("node:assert/strict"));
|
|
5
|
+
const coverageCalculator_1 = require("../../src/resources/coverageCalculator");
|
|
6
|
+
describe('computeCoverage', () => {
|
|
7
|
+
it('computes coverage for fully mapped fields', () => {
|
|
8
|
+
const rawApiFields = ['id', 'name', 'status'];
|
|
9
|
+
const schemaFields = ['id', 'name', 'status'];
|
|
10
|
+
const result = (0, coverageCalculator_1.computeCoverage)(rawApiFields, schemaFields);
|
|
11
|
+
strict_1.default.deepEqual(result.mappedFields, ['id', 'name', 'status']);
|
|
12
|
+
strict_1.default.deepEqual(result.unmappedFields, []);
|
|
13
|
+
strict_1.default.equal(result.coveragePercent, 100);
|
|
14
|
+
});
|
|
15
|
+
it('computes coverage with unmapped fields', () => {
|
|
16
|
+
const rawApiFields = ['id', 'name', 'status', 'priority', 'created_at'];
|
|
17
|
+
const schemaFields = ['id', 'name', 'status'];
|
|
18
|
+
const result = (0, coverageCalculator_1.computeCoverage)(rawApiFields, schemaFields);
|
|
19
|
+
strict_1.default.deepEqual(result.mappedFields, ['id', 'name', 'status']);
|
|
20
|
+
strict_1.default.deepEqual(result.unmappedFields, ['created_at', 'priority']);
|
|
21
|
+
strict_1.default.equal(result.coveragePercent, 60);
|
|
22
|
+
});
|
|
23
|
+
it('handles no raw API fields', () => {
|
|
24
|
+
const result = (0, coverageCalculator_1.computeCoverage)([], ['id', 'name']);
|
|
25
|
+
strict_1.default.deepEqual(result.mappedFields, []);
|
|
26
|
+
strict_1.default.deepEqual(result.unmappedFields, []);
|
|
27
|
+
strict_1.default.equal(result.coveragePercent, 100);
|
|
28
|
+
});
|
|
29
|
+
it('handles no schema fields', () => {
|
|
30
|
+
const result = (0, coverageCalculator_1.computeCoverage)(['id', 'name'], []);
|
|
31
|
+
strict_1.default.deepEqual(result.mappedFields, []);
|
|
32
|
+
strict_1.default.deepEqual(result.unmappedFields, ['id', 'name']);
|
|
33
|
+
strict_1.default.equal(result.coveragePercent, 0);
|
|
34
|
+
});
|
|
35
|
+
it('matches nested dot-notation fields against flat schema names', () => {
|
|
36
|
+
const rawApiFields = ['id', 'assignee', 'assignee.id', 'assignee.name'];
|
|
37
|
+
const schemaFields = ['id', 'assignee'];
|
|
38
|
+
const result = (0, coverageCalculator_1.computeCoverage)(rawApiFields, schemaFields);
|
|
39
|
+
strict_1.default.deepEqual(result.mappedFields, ['assignee', 'id']);
|
|
40
|
+
strict_1.default.deepEqual(result.unmappedFields, ['assignee.id', 'assignee.name']);
|
|
41
|
+
strict_1.default.equal(result.coveragePercent, 50);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('applyAllowlist', () => {
|
|
45
|
+
it('suppresses exact field matches', () => {
|
|
46
|
+
const unmappedFields = ['priority', 'created_at', 'labels'];
|
|
47
|
+
const allowlist = [{ field: 'priority', reason: 'Not useful' }];
|
|
48
|
+
const result = (0, coverageCalculator_1.applyAllowlist)(unmappedFields, allowlist);
|
|
49
|
+
strict_1.default.deepEqual(result, ['created_at', 'labels']);
|
|
50
|
+
});
|
|
51
|
+
it('suppresses glob patterns with *', () => {
|
|
52
|
+
const unmappedFields = ['metadata', 'metadata.created_at', 'metadata.updated_at', 'labels'];
|
|
53
|
+
const allowlist = [{ field: 'metadata.*', reason: 'Internal tracking' }];
|
|
54
|
+
const result = (0, coverageCalculator_1.applyAllowlist)(unmappedFields, allowlist);
|
|
55
|
+
strict_1.default.deepEqual(result, ['labels', 'metadata']);
|
|
56
|
+
});
|
|
57
|
+
it('suppresses glob patterns with **', () => {
|
|
58
|
+
const unmappedFields = ['meta', 'meta.a', 'meta.b.c', 'meta.b.c.d', 'name'];
|
|
59
|
+
const allowlist = [{ field: 'meta.**', reason: 'All metadata' }];
|
|
60
|
+
const result = (0, coverageCalculator_1.applyAllowlist)(unmappedFields, allowlist);
|
|
61
|
+
strict_1.default.deepEqual(result, ['meta', 'name']);
|
|
62
|
+
});
|
|
63
|
+
it('returns all fields when allowlist is empty', () => {
|
|
64
|
+
const unmappedFields = ['a', 'b', 'c'];
|
|
65
|
+
strict_1.default.deepEqual((0, coverageCalculator_1.applyAllowlist)(unmappedFields, []), ['a', 'b', 'c']);
|
|
66
|
+
});
|
|
67
|
+
it('sorts output even with empty allowlist', () => {
|
|
68
|
+
const unmappedFields = ['z', 'a', 'name'];
|
|
69
|
+
strict_1.default.deepEqual((0, coverageCalculator_1.applyAllowlist)(unmappedFields, []), ['a', 'name', 'z']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const strict_1 = tslib_1.__importDefault(require("node:assert/strict"));
|
|
5
|
+
const fieldExtractor_1 = require("../../src/resources/fieldExtractor");
|
|
6
|
+
describe('extractFields', () => {
|
|
7
|
+
it('extracts top-level primitive fields', () => {
|
|
8
|
+
const body = { id: '123', name: 'Task', status: 'open' };
|
|
9
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['id', 'name', 'status']);
|
|
10
|
+
});
|
|
11
|
+
it('extracts nested object fields with dot notation', () => {
|
|
12
|
+
const body = { id: '123', assignee: { id: '456', name: 'Alice' } };
|
|
13
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['assignee', 'assignee.id', 'assignee.name', 'id']);
|
|
14
|
+
});
|
|
15
|
+
it('extracts deeply nested fields', () => {
|
|
16
|
+
const body = { meta: { creator: { id: '1', name: 'Bob' } } };
|
|
17
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['meta', 'meta.creator', 'meta.creator.id', 'meta.creator.name']);
|
|
18
|
+
});
|
|
19
|
+
it('handles arrays of primitives as a single field', () => {
|
|
20
|
+
const body = { id: '123', labels: ['bug', 'urgent'] };
|
|
21
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['id', 'labels']);
|
|
22
|
+
});
|
|
23
|
+
it('extracts fields from first item of array of objects', () => {
|
|
24
|
+
const body = { id: '123', items: [{ name: 'A', value: 1 }, { name: 'B' }] };
|
|
25
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['id', 'items', 'items.name', 'items.value']);
|
|
26
|
+
});
|
|
27
|
+
it('handles top-level array by extracting from first item', () => {
|
|
28
|
+
const body = [{ id: '1', name: 'Task' }, { id: '2' }];
|
|
29
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), ['id', 'name']);
|
|
30
|
+
});
|
|
31
|
+
it('returns empty array for null/undefined', () => {
|
|
32
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(null), []);
|
|
33
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(undefined), []);
|
|
34
|
+
});
|
|
35
|
+
it('returns empty array for empty object', () => {
|
|
36
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)({}), []);
|
|
37
|
+
});
|
|
38
|
+
it('returns empty array for empty array', () => {
|
|
39
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)([]), []);
|
|
40
|
+
});
|
|
41
|
+
it('handles mixed nested structures', () => {
|
|
42
|
+
const body = {
|
|
43
|
+
id: '123',
|
|
44
|
+
owner: { id: '456', profile: { avatar: 'url', bio: 'hello' } },
|
|
45
|
+
tags: ['a', 'b'],
|
|
46
|
+
settings: { color: 'blue' },
|
|
47
|
+
};
|
|
48
|
+
strict_1.default.deepEqual((0, fieldExtractor_1.extractFields)(body), [
|
|
49
|
+
'id',
|
|
50
|
+
'owner',
|
|
51
|
+
'owner.id',
|
|
52
|
+
'owner.profile',
|
|
53
|
+
'owner.profile.avatar',
|
|
54
|
+
'owner.profile.bio',
|
|
55
|
+
'settings',
|
|
56
|
+
'settings.color',
|
|
57
|
+
'tags',
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tslib_1 = require("tslib");
|
|
4
|
+
const strict_1 = tslib_1.__importDefault(require("node:assert/strict"));
|
|
5
|
+
const fs_1 = tslib_1.__importDefault(require("fs"));
|
|
6
|
+
const tmp_1 = tslib_1.__importDefault(require("tmp"));
|
|
7
|
+
const jsonlReader_1 = require("../../src/resources/jsonlReader");
|
|
8
|
+
describe('readNewEntries', () => {
|
|
9
|
+
let tempFile;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tempFile = tmp_1.default.fileSync({ postfix: '.jsonl' });
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
tempFile.removeCallback();
|
|
15
|
+
});
|
|
16
|
+
it('reads all entries from a new file', () => {
|
|
17
|
+
const entries = [
|
|
18
|
+
{ seq: 1, url: 'https://api.example.com/items', method: 'GET', status: 200, body: { id: '1', name: 'A' } },
|
|
19
|
+
{
|
|
20
|
+
seq: 2,
|
|
21
|
+
url: 'https://api.example.com/items/1',
|
|
22
|
+
method: 'GET',
|
|
23
|
+
status: 200,
|
|
24
|
+
body: { id: '1', name: 'A', desc: 'B' },
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
fs_1.default.writeFileSync(tempFile.name, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
28
|
+
const result = (0, jsonlReader_1.readNewEntries)(tempFile.name, 0);
|
|
29
|
+
strict_1.default.equal(result.entries.length, 2);
|
|
30
|
+
strict_1.default.equal(result.entries[0].seq, 1);
|
|
31
|
+
strict_1.default.equal(result.entries[1].seq, 2);
|
|
32
|
+
strict_1.default.equal(result.lastSeq, 2);
|
|
33
|
+
});
|
|
34
|
+
it('reads only entries after a given seq', () => {
|
|
35
|
+
const entries = [
|
|
36
|
+
{ seq: 1, url: 'https://api.example.com/a', method: 'GET', status: 200, body: {} },
|
|
37
|
+
{ seq: 2, url: 'https://api.example.com/b', method: 'GET', status: 200, body: {} },
|
|
38
|
+
{ seq: 3, url: 'https://api.example.com/c', method: 'GET', status: 200, body: {} },
|
|
39
|
+
];
|
|
40
|
+
fs_1.default.writeFileSync(tempFile.name, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
41
|
+
const result = (0, jsonlReader_1.readNewEntries)(tempFile.name, 1);
|
|
42
|
+
strict_1.default.equal(result.entries.length, 2);
|
|
43
|
+
strict_1.default.equal(result.entries[0].seq, 2);
|
|
44
|
+
strict_1.default.equal(result.lastSeq, 3);
|
|
45
|
+
});
|
|
46
|
+
it('returns empty when no new entries', () => {
|
|
47
|
+
const entries = [
|
|
48
|
+
{ seq: 1, url: 'https://api.example.com/a', method: 'GET', status: 200, body: {} },
|
|
49
|
+
];
|
|
50
|
+
fs_1.default.writeFileSync(tempFile.name, entries.map(e => JSON.stringify(e)).join('\n') + '\n');
|
|
51
|
+
const result = (0, jsonlReader_1.readNewEntries)(tempFile.name, 1);
|
|
52
|
+
strict_1.default.equal(result.entries.length, 0);
|
|
53
|
+
strict_1.default.equal(result.lastSeq, 1);
|
|
54
|
+
});
|
|
55
|
+
it('returns empty when file does not exist', () => {
|
|
56
|
+
const result = (0, jsonlReader_1.readNewEntries)('/nonexistent/path.jsonl', 0);
|
|
57
|
+
strict_1.default.equal(result.entries.length, 0);
|
|
58
|
+
strict_1.default.equal(result.lastSeq, 0);
|
|
59
|
+
});
|
|
60
|
+
it('skips malformed lines gracefully', () => {
|
|
61
|
+
fs_1.default.writeFileSync(tempFile.name, '{"seq":1,"url":"a","method":"GET","status":200,"body":{}}\nnot-json\n{"seq":2,"url":"b","method":"GET","status":200,"body":{}}\n');
|
|
62
|
+
const result = (0, jsonlReader_1.readNewEntries)(tempFile.name, 0);
|
|
63
|
+
strict_1.default.equal(result.entries.length, 2);
|
|
64
|
+
strict_1.default.equal(result.lastSeq, 2);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../src/baseCommand.ts","../src/configurationTypes.ts","../src/errors.ts","../src/index.ts","../src/commands/activity.ts","../src/commands/dev.ts","../src/commands/encrypt.ts","../src/commands/graph.ts","../src/commands/init.ts","../src/commands/invite.ts","../src/commands/login.ts","../src/commands/oauth2.ts","../src/commands/publish.ts","../src/commands/test.ts","../src/commands/upgrade.ts","../src/hooks/init/displayLogo.ts","../src/hooks/init/updateNotifier.ts","../src/resources/configuration.ts","../src/resources/credentials.ts","../src/resources/decryption.ts","../src/resources/fileSystem.ts","../src/resources/globalConfiguration.ts","../src/resources/integrations.ts","../src/resources/integrationsPlatform.ts","../src/resources/oauth2.ts","../src/resources/template.ts","../src/services/integrationsPlatform.ts","../src/services/integrationsPlatformClient.ts","../src/services/oauth2.ts","../test/errors.test.ts","../test/commands/activity.test.ts","../test/commands/dev.test.ts","../test/commands/encrypt.test.ts","../test/commands/graph.test.ts","../test/commands/init.test.ts","../test/commands/invite.test.ts","../test/commands/login.test.ts","../test/commands/oauth2.test.ts","../test/commands/publish.test.ts","../test/commands/test.test.ts","../test/commands/upgrade.test.ts","../test/helpers/init.js","../test/helpers/integrations.ts","../test/helpers/styles.ts","../test/hooks/updateNotifier.test.ts","../test/resources/configuration.test.ts","../test/resources/decryption.test.ts","../test/resources/globalConfiguration.test.ts","../test/resources/integrations.test.ts","../test/resources/oauth2.test.ts","../test/resources/template.test.ts","../test/services/integrationsPlatform.test.ts","../test/services/oauth2.test.ts","../scripts/generateTypes.ts","../.eslintrc.js"],"version":"5.9.3"}
|
|
1
|
+
{"root":["../src/baseCommand.ts","../src/configurationTypes.ts","../src/errors.ts","../src/index.ts","../src/commands/activity.ts","../src/commands/dev.ts","../src/commands/encrypt.ts","../src/commands/graph.ts","../src/commands/init.ts","../src/commands/invite.ts","../src/commands/login.ts","../src/commands/oauth2.ts","../src/commands/publish.ts","../src/commands/schema-snapshot.ts","../src/commands/test.ts","../src/commands/upgrade.ts","../src/hooks/init/displayLogo.ts","../src/hooks/init/updateNotifier.ts","../src/resources/configuration.ts","../src/resources/coverageCalculator.ts","../src/resources/credentials.ts","../src/resources/decryption.ts","../src/resources/fieldExtractor.ts","../src/resources/fileSystem.ts","../src/resources/globalConfiguration.ts","../src/resources/integrations.ts","../src/resources/integrationsPlatform.ts","../src/resources/jsonlReader.ts","../src/resources/oauth2.ts","../src/resources/template.ts","../src/services/integrationsPlatform.ts","../src/services/integrationsPlatformClient.ts","../src/services/oauth2.ts","../test/errors.test.ts","../test/commands/activity.test.ts","../test/commands/dev.test.ts","../test/commands/encrypt.test.ts","../test/commands/graph.test.ts","../test/commands/init.test.ts","../test/commands/invite.test.ts","../test/commands/login.test.ts","../test/commands/oauth2.test.ts","../test/commands/publish.test.ts","../test/commands/test.test.ts","../test/commands/upgrade.test.ts","../test/helpers/init.js","../test/helpers/integrations.ts","../test/helpers/styles.ts","../test/hooks/updateNotifier.test.ts","../test/resources/configuration.test.ts","../test/resources/coverageCalculator.test.ts","../test/resources/decryption.test.ts","../test/resources/fieldExtractor.test.ts","../test/resources/globalConfiguration.test.ts","../test/resources/integrations.test.ts","../test/resources/jsonlReader.test.ts","../test/resources/oauth2.test.ts","../test/resources/template.test.ts","../test/services/integrationsPlatform.test.ts","../test/services/oauth2.test.ts","../scripts/generateTypes.ts","../.eslintrc.js"],"version":"5.9.3"}
|
package/oclif.manifest.json
CHANGED
|
@@ -667,6 +667,102 @@
|
|
|
667
667
|
"publish.js"
|
|
668
668
|
]
|
|
669
669
|
},
|
|
670
|
+
"schema-snapshot": {
|
|
671
|
+
"aliases": [],
|
|
672
|
+
"args": {},
|
|
673
|
+
"description": "Generate a schema snapshot by crawling the integration graph",
|
|
674
|
+
"examples": [
|
|
675
|
+
"<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json",
|
|
676
|
+
"<%= config.bin %> <%= command.id %> --output=./schema-snapshot.json --diff=./previous-snapshot.json"
|
|
677
|
+
],
|
|
678
|
+
"flags": {
|
|
679
|
+
"json": {
|
|
680
|
+
"description": "Format output as json.",
|
|
681
|
+
"helpGroup": "GLOBAL",
|
|
682
|
+
"name": "json",
|
|
683
|
+
"allowNo": false,
|
|
684
|
+
"type": "boolean"
|
|
685
|
+
},
|
|
686
|
+
"output": {
|
|
687
|
+
"description": "path to write the schema snapshot",
|
|
688
|
+
"name": "output",
|
|
689
|
+
"required": true,
|
|
690
|
+
"hasDynamicHelp": false,
|
|
691
|
+
"multiple": false,
|
|
692
|
+
"type": "option"
|
|
693
|
+
},
|
|
694
|
+
"diff": {
|
|
695
|
+
"description": "path to a previous snapshot for comparison",
|
|
696
|
+
"name": "diff",
|
|
697
|
+
"hasDynamicHelp": false,
|
|
698
|
+
"multiple": false,
|
|
699
|
+
"type": "option"
|
|
700
|
+
},
|
|
701
|
+
"environment": {
|
|
702
|
+
"description": "the environment of the platform",
|
|
703
|
+
"name": "environment",
|
|
704
|
+
"default": "production",
|
|
705
|
+
"hasDynamicHelp": false,
|
|
706
|
+
"multiple": false,
|
|
707
|
+
"options": [
|
|
708
|
+
"local",
|
|
709
|
+
"staging",
|
|
710
|
+
"production"
|
|
711
|
+
],
|
|
712
|
+
"type": "option"
|
|
713
|
+
},
|
|
714
|
+
"test-account": {
|
|
715
|
+
"description": "test account to use",
|
|
716
|
+
"name": "test-account",
|
|
717
|
+
"default": "development",
|
|
718
|
+
"hasDynamicHelp": false,
|
|
719
|
+
"multiple": false,
|
|
720
|
+
"type": "option"
|
|
721
|
+
},
|
|
722
|
+
"config-path": {
|
|
723
|
+
"description": "relative path to a custom \".unito.json\" file",
|
|
724
|
+
"name": "config-path",
|
|
725
|
+
"hasDynamicHelp": false,
|
|
726
|
+
"multiple": false,
|
|
727
|
+
"type": "option"
|
|
728
|
+
},
|
|
729
|
+
"skip-install": {
|
|
730
|
+
"description": "skip npm install before starting",
|
|
731
|
+
"name": "skip-install",
|
|
732
|
+
"allowNo": false,
|
|
733
|
+
"type": "boolean"
|
|
734
|
+
},
|
|
735
|
+
"strict": {
|
|
736
|
+
"description": "fail if any fields are unmapped and not in the allowlist",
|
|
737
|
+
"name": "strict",
|
|
738
|
+
"allowNo": false,
|
|
739
|
+
"type": "boolean"
|
|
740
|
+
},
|
|
741
|
+
"port": {
|
|
742
|
+
"description": "port to start the integration on (default: 9200)",
|
|
743
|
+
"name": "port",
|
|
744
|
+
"default": 9200,
|
|
745
|
+
"hasDynamicHelp": false,
|
|
746
|
+
"multiple": false,
|
|
747
|
+
"type": "option"
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
"hasDynamicHelp": false,
|
|
751
|
+
"hiddenAliases": [],
|
|
752
|
+
"id": "schema-snapshot",
|
|
753
|
+
"pluginAlias": "@unito/integration-cli",
|
|
754
|
+
"pluginName": "@unito/integration-cli",
|
|
755
|
+
"pluginType": "core",
|
|
756
|
+
"strict": true,
|
|
757
|
+
"enableJsonFlag": true,
|
|
758
|
+
"isESM": false,
|
|
759
|
+
"relativePath": [
|
|
760
|
+
"dist",
|
|
761
|
+
"src",
|
|
762
|
+
"commands",
|
|
763
|
+
"schema-snapshot.js"
|
|
764
|
+
]
|
|
765
|
+
},
|
|
670
766
|
"test": {
|
|
671
767
|
"aliases": [],
|
|
672
768
|
"args": {},
|
|
@@ -873,5 +969,5 @@
|
|
|
873
969
|
]
|
|
874
970
|
}
|
|
875
971
|
},
|
|
876
|
-
"version": "0.
|
|
972
|
+
"version": "0.66.0"
|
|
877
973
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unito/integration-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Integration CLI",
|
|
5
5
|
"bin": {
|
|
6
6
|
"integration-cli": "./bin/run"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@ngrok/ngrok": "^1.4.1",
|
|
40
40
|
"@oazapfts/runtime": "1.x",
|
|
41
41
|
"@oclif/core": "3.x",
|
|
42
|
-
"@unito/integration-debugger": "0.
|
|
42
|
+
"@unito/integration-debugger": "0.29.0",
|
|
43
43
|
"ajv": "8.x",
|
|
44
44
|
"ajv-formats": "3.x",
|
|
45
45
|
"better-ajv-errors": "1.x",
|