@x12i/memorix-hippox 1.0.1 → 1.2.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 +112 -13
- package/dist/collection-resolver.d.ts.map +1 -1
- package/dist/collection-resolver.js +8 -1
- package/dist/collection-resolver.js.map +1 -1
- package/dist/config.d.ts +11 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -1
- package/dist/config.js.map +1 -1
- package/dist/helpers-loader.d.ts +31 -0
- package/dist/helpers-loader.d.ts.map +1 -0
- package/dist/helpers-loader.js +20 -0
- package/dist/helpers-loader.js.map +1 -0
- package/dist/http/register-routes.d.ts.map +1 -1
- package/dist/http/register-routes.js +73 -1
- package/dist/http/register-routes.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/infer-links.d.ts +9 -0
- package/dist/infer-links.d.ts.map +1 -0
- package/dist/infer-links.js +171 -0
- package/dist/infer-links.js.map +1 -0
- package/dist/join-discovery.d.ts +34 -0
- package/dist/join-discovery.d.ts.map +1 -0
- package/dist/join-discovery.js +697 -0
- package/dist/join-discovery.js.map +1 -0
- package/dist/legacy-bridge.d.ts.map +1 -1
- package/dist/legacy-bridge.js +3 -4
- package/dist/legacy-bridge.js.map +1 -1
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +17 -0
- package/dist/paths.js.map +1 -1
- package/dist/record-flatten.d.ts +12 -0
- package/dist/record-flatten.d.ts.map +1 -0
- package/dist/record-flatten.js +49 -0
- package/dist/record-flatten.js.map +1 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +6 -5
- package/dist/resolve.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +33 -0
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +4 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +16 -0
- package/dist/store.js.map +1 -1
- package/dist/types.d.ts +121 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import { resolveDefaultListDescriptorForEntity } from '@x12i/memorix-retrieval';
|
|
2
|
+
import { listEntityRelations } from '@x12i/memorix-mongo';
|
|
3
|
+
import { loadHippoxConfig } from './config.js';
|
|
4
|
+
import { countObjectTypeDocuments, readObjectTypeDocuments, resolveObjectTypeCollection, } from './collection-resolver.js';
|
|
5
|
+
import { isDateLike, readPath } from './paths.js';
|
|
6
|
+
const ID_SUFFIX_RE = /(?:Id|_id|Ref|_ref)$/i;
|
|
7
|
+
const DEFAULT_DATA_ROOT = 'data';
|
|
8
|
+
const discoveryCache = new Map();
|
|
9
|
+
export function clearJoinDiscoveryCache() {
|
|
10
|
+
discoveryCache.clear();
|
|
11
|
+
}
|
|
12
|
+
export async function discoverJoinCandidates(options) {
|
|
13
|
+
const config = loadHippoxConfig(options.processEnv);
|
|
14
|
+
const input = options.input;
|
|
15
|
+
const sourceObjectType = input.sourceObjectType?.trim();
|
|
16
|
+
const targetObjectType = input.targetObjectType?.trim();
|
|
17
|
+
if (!sourceObjectType || !targetObjectType) {
|
|
18
|
+
throw new Error('sourceObjectType and targetObjectType are required');
|
|
19
|
+
}
|
|
20
|
+
const sampleLimit = Math.min(input.sampleLimit ?? config.discoverySampleLimit, config.discoverySampleLimit);
|
|
21
|
+
const direction = input.direction ?? 'downstream';
|
|
22
|
+
const cacheKey = [
|
|
23
|
+
sourceObjectType,
|
|
24
|
+
targetObjectType,
|
|
25
|
+
input.sourceContentType ?? '',
|
|
26
|
+
input.targetContentType ?? '',
|
|
27
|
+
direction,
|
|
28
|
+
String(sampleLimit),
|
|
29
|
+
].join('|');
|
|
30
|
+
const cached = discoveryCache.get(cacheKey);
|
|
31
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
32
|
+
return cached.result;
|
|
33
|
+
}
|
|
34
|
+
await assertObjectTypeExists(options.retrieval, sourceObjectType);
|
|
35
|
+
await assertObjectTypeExists(options.retrieval, targetObjectType);
|
|
36
|
+
const sourceBinding = await resolveObjectTypeCollection(options.retrieval, sourceObjectType, input.sourceContentType);
|
|
37
|
+
const targetBinding = await resolveObjectTypeCollection(options.retrieval, targetObjectType, input.targetContentType);
|
|
38
|
+
const [sourceCount, targetCount] = await Promise.all([
|
|
39
|
+
countObjectTypeDocuments(options.retrieval, sourceBinding),
|
|
40
|
+
countObjectTypeDocuments(options.retrieval, targetBinding),
|
|
41
|
+
]);
|
|
42
|
+
const warnings = [];
|
|
43
|
+
if (sourceCount === 0 && targetCount === 0) {
|
|
44
|
+
const empty = buildEmptyResult({
|
|
45
|
+
sourceObjectType,
|
|
46
|
+
targetObjectType,
|
|
47
|
+
sourceContentType: sourceBinding.contentType,
|
|
48
|
+
targetContentType: targetBinding.contentType,
|
|
49
|
+
sampleLimit,
|
|
50
|
+
sourceRecordCount: sourceCount,
|
|
51
|
+
targetRecordCount: targetCount,
|
|
52
|
+
warnings: [{ code: 'no_sample_data', message: 'Both collections are empty.' }],
|
|
53
|
+
});
|
|
54
|
+
cacheResult(cacheKey, empty, config.discoveryCacheTtlMs);
|
|
55
|
+
return empty;
|
|
56
|
+
}
|
|
57
|
+
const driveFromSource = sourceCount <= targetCount;
|
|
58
|
+
const smallerCount = driveFromSource ? sourceCount : targetCount;
|
|
59
|
+
const sampleSize = Math.min(sampleLimit, Math.max(smallerCount, 0));
|
|
60
|
+
const [sourceDocs, targetDocs, seeds, existingLinks] = await Promise.all([
|
|
61
|
+
sourceCount > 0
|
|
62
|
+
? readObjectTypeDocuments(options.retrieval, sourceBinding, { limit: sampleSize })
|
|
63
|
+
: Promise.resolve([]),
|
|
64
|
+
targetCount > 0
|
|
65
|
+
? readObjectTypeDocuments(options.retrieval, targetBinding, { limit: sampleSize })
|
|
66
|
+
: Promise.resolve([]),
|
|
67
|
+
loadDiscoverySeeds(options.retrieval, sourceObjectType, targetObjectType),
|
|
68
|
+
options.store?.findLinksForObjectTypePair(sourceObjectType, targetObjectType)
|
|
69
|
+
?? Promise.resolve({ approved: undefined, simulated: undefined }),
|
|
70
|
+
]);
|
|
71
|
+
const sourceDataRoot = await resolveDataRoot(options.retrieval, sourceObjectType, sourceBinding.contentType);
|
|
72
|
+
const targetDataRoot = await resolveDataRoot(options.retrieval, targetObjectType, targetBinding.contentType);
|
|
73
|
+
const approvedJoin = existingLinks.approved?.join[0];
|
|
74
|
+
const simulatedJoin = existingLinks.simulated?.join[0];
|
|
75
|
+
const mergedSeeds = {
|
|
76
|
+
schemaPairs: seeds.schemaPairs,
|
|
77
|
+
approvedJoin: approvedJoin
|
|
78
|
+
? {
|
|
79
|
+
sourcePath: normalizeDocumentPath(approvedJoin.sourcePath, sourceDataRoot),
|
|
80
|
+
targetPath: normalizeDocumentPath(approvedJoin.targetPath, targetDataRoot),
|
|
81
|
+
operator: approvedJoin.operator ?? 'eq',
|
|
82
|
+
}
|
|
83
|
+
: undefined,
|
|
84
|
+
simulatedJoin: simulatedJoin
|
|
85
|
+
? {
|
|
86
|
+
sourcePath: normalizeDocumentPath(simulatedJoin.sourcePath, sourceDataRoot),
|
|
87
|
+
targetPath: normalizeDocumentPath(simulatedJoin.targetPath, targetDataRoot),
|
|
88
|
+
operator: simulatedJoin.operator ?? 'eq',
|
|
89
|
+
}
|
|
90
|
+
: undefined,
|
|
91
|
+
};
|
|
92
|
+
const sourceInventory = buildFieldInventory(sourceDocs, sourceDataRoot, config);
|
|
93
|
+
const targetInventory = buildFieldInventory(targetDocs, targetDataRoot, config);
|
|
94
|
+
applySchemaSeeds(sourceInventory, targetInventory, mergedSeeds.schemaPairs, config.discoveryMinCoverage);
|
|
95
|
+
markApprovedFields(sourceInventory, targetInventory, mergedSeeds);
|
|
96
|
+
if (sourceInventory.length === 0 && targetInventory.length === 0) {
|
|
97
|
+
warnings.push({
|
|
98
|
+
code: 'no_joinable_fields',
|
|
99
|
+
message: 'No scalar join-safe fields were discovered in sampled documents.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const nameTargetType = direction === 'downstream' ? targetObjectType : sourceObjectType;
|
|
103
|
+
const pairProbes = probeJoinPairs({
|
|
104
|
+
sourceFields: sourceInventory,
|
|
105
|
+
targetFields: targetInventory,
|
|
106
|
+
targetObjectType: nameTargetType,
|
|
107
|
+
sourceObjectType,
|
|
108
|
+
seeds: mergedSeeds,
|
|
109
|
+
distinctCap: config.discoveryDistinctCap,
|
|
110
|
+
maxPairs: config.discoveryMaxPairs,
|
|
111
|
+
minCoverage: config.discoveryMinCoverage,
|
|
112
|
+
});
|
|
113
|
+
const sourceFields = rankSourceFields(sourceInventory, pairProbes, nameTargetType, mergedSeeds);
|
|
114
|
+
const targetFields = rankTargetFields(targetInventory, pairProbes, sourceObjectType, mergedSeeds);
|
|
115
|
+
const joinCandidates = rankJoinCandidates({
|
|
116
|
+
probes: pairProbes,
|
|
117
|
+
minPairScore: config.discoveryMinPairScore,
|
|
118
|
+
maxCandidates: config.discoveryMaxCandidates,
|
|
119
|
+
approvedJoin: mergedSeeds.approvedJoin,
|
|
120
|
+
});
|
|
121
|
+
if (sourceCount > sampleLimit || targetCount > sampleLimit) {
|
|
122
|
+
warnings.push({
|
|
123
|
+
code: 'sampled_mode',
|
|
124
|
+
message: `Discovery used sampling with limit ${sampleLimit}.`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const recommendedJoin = joinCandidates.length > 0 && joinCandidates[0].score >= config.discoveryMinPairScore
|
|
128
|
+
? {
|
|
129
|
+
sourcePath: joinCandidates[0].sourcePath,
|
|
130
|
+
targetPath: joinCandidates[0].targetPath,
|
|
131
|
+
operator: joinCandidates[0].operator,
|
|
132
|
+
}
|
|
133
|
+
: null;
|
|
134
|
+
const result = {
|
|
135
|
+
sourceObjectType,
|
|
136
|
+
targetObjectType,
|
|
137
|
+
sourceContentType: sourceBinding.contentType,
|
|
138
|
+
targetContentType: targetBinding.contentType,
|
|
139
|
+
mode: 'sampled',
|
|
140
|
+
sampleLimit,
|
|
141
|
+
sourceRecordCount: sourceCount,
|
|
142
|
+
targetRecordCount: targetCount,
|
|
143
|
+
sourceFields,
|
|
144
|
+
targetFields,
|
|
145
|
+
joinCandidates,
|
|
146
|
+
recommendedJoin,
|
|
147
|
+
warnings,
|
|
148
|
+
};
|
|
149
|
+
cacheResult(cacheKey, result, config.discoveryCacheTtlMs);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
function cacheResult(key, result, ttlMs) {
|
|
153
|
+
if (ttlMs <= 0)
|
|
154
|
+
return;
|
|
155
|
+
discoveryCache.set(key, { expiresAt: Date.now() + ttlMs, result });
|
|
156
|
+
}
|
|
157
|
+
function buildEmptyResult(params) {
|
|
158
|
+
return {
|
|
159
|
+
sourceObjectType: params.sourceObjectType,
|
|
160
|
+
targetObjectType: params.targetObjectType,
|
|
161
|
+
sourceContentType: params.sourceContentType,
|
|
162
|
+
targetContentType: params.targetContentType,
|
|
163
|
+
mode: 'sampled',
|
|
164
|
+
sampleLimit: params.sampleLimit,
|
|
165
|
+
sourceRecordCount: params.sourceRecordCount,
|
|
166
|
+
targetRecordCount: params.targetRecordCount,
|
|
167
|
+
sourceFields: [],
|
|
168
|
+
targetFields: [],
|
|
169
|
+
joinCandidates: [],
|
|
170
|
+
recommendedJoin: null,
|
|
171
|
+
warnings: params.warnings,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function assertObjectTypeExists(retrieval, objectType) {
|
|
175
|
+
try {
|
|
176
|
+
await resolveDefaultListDescriptorForEntity(retrieval, objectType);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
throw new Error(`Object type not found: ${objectType}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function resolveDataRoot(retrieval, objectType, contentTypeKey) {
|
|
183
|
+
try {
|
|
184
|
+
const { entity } = await resolveDefaultListDescriptorForEntity(retrieval, objectType);
|
|
185
|
+
const ct = entity.contentTypes?.[contentTypeKey];
|
|
186
|
+
if (ct && typeof ct.dataRoot === 'string')
|
|
187
|
+
return ct.dataRoot;
|
|
188
|
+
if (entity.defaults?.dataRoot)
|
|
189
|
+
return entity.defaults.dataRoot;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// fall through
|
|
193
|
+
}
|
|
194
|
+
return DEFAULT_DATA_ROOT;
|
|
195
|
+
}
|
|
196
|
+
async function loadDiscoverySeeds(retrieval, sourceObjectType, targetObjectType) {
|
|
197
|
+
const relations = await listEntityRelations(retrieval, sourceObjectType);
|
|
198
|
+
const schemaPairs = relations
|
|
199
|
+
.filter((rel) => rel.targetEntity === targetObjectType)
|
|
200
|
+
.map((rel) => ({
|
|
201
|
+
sourcePath: rel.sourcePath,
|
|
202
|
+
targetPath: rel.targetPath,
|
|
203
|
+
}));
|
|
204
|
+
return { schemaPairs };
|
|
205
|
+
}
|
|
206
|
+
function normalizeDocumentPath(path, dataRoot) {
|
|
207
|
+
const trimmed = path.trim();
|
|
208
|
+
if (!trimmed)
|
|
209
|
+
return trimmed;
|
|
210
|
+
if (trimmed.startsWith(`${dataRoot}.`) || trimmed === dataRoot)
|
|
211
|
+
return trimmed;
|
|
212
|
+
if (trimmed.startsWith('data.'))
|
|
213
|
+
return trimmed;
|
|
214
|
+
return `${dataRoot}.${trimmed}`;
|
|
215
|
+
}
|
|
216
|
+
function buildFieldInventory(docs, dataRoot, config) {
|
|
217
|
+
const accumulators = new Map();
|
|
218
|
+
const sampleCount = docs.length;
|
|
219
|
+
for (const doc of docs) {
|
|
220
|
+
const payload = readPath(doc, dataRoot);
|
|
221
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
222
|
+
continue;
|
|
223
|
+
collectScalarPaths(payload, dataRoot, accumulators);
|
|
224
|
+
}
|
|
225
|
+
const paths = [...accumulators.keys()];
|
|
226
|
+
for (const doc of docs) {
|
|
227
|
+
for (const path of paths) {
|
|
228
|
+
const value = readPath(doc, path);
|
|
229
|
+
touchFieldObservation(accumulators.get(path), value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const acc of accumulators.values()) {
|
|
234
|
+
const coverage = sampleCount > 0 ? acc.presentCount / sampleCount : 0;
|
|
235
|
+
if (coverage < config.discoveryMinCoverage && !acc.schemaSeeded)
|
|
236
|
+
continue;
|
|
237
|
+
const inferredType = mergeInferredTypes(acc.types);
|
|
238
|
+
if (!isJoinSafeType(inferredType) && !acc.schemaSeeded)
|
|
239
|
+
continue;
|
|
240
|
+
const nonNullCount = acc.presentCount;
|
|
241
|
+
const distinctValueCount = acc.distinctValues.size;
|
|
242
|
+
out.push({
|
|
243
|
+
path: acc.path,
|
|
244
|
+
inferredType,
|
|
245
|
+
coverage,
|
|
246
|
+
distinctValueCount,
|
|
247
|
+
uniqueness: nonNullCount > 0 ? distinctValueCount / nonNullCount : 0,
|
|
248
|
+
nonNullCount,
|
|
249
|
+
distinctValues: acc.distinctValues,
|
|
250
|
+
schemaSeeded: acc.schemaSeeded,
|
|
251
|
+
approvedSource: acc.approvedSource,
|
|
252
|
+
approvedTarget: acc.approvedTarget,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return out.sort((a, b) => a.path.localeCompare(b.path));
|
|
256
|
+
}
|
|
257
|
+
function collectScalarPaths(obj, prefix, accumulators) {
|
|
258
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
259
|
+
const path = `${prefix}.${key}`;
|
|
260
|
+
if (value === null || value === undefined) {
|
|
261
|
+
ensureField(accumulators, path);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(value))
|
|
265
|
+
continue;
|
|
266
|
+
if (typeof value === 'object' && !(value instanceof Date)) {
|
|
267
|
+
collectScalarPaths(value, path, accumulators);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
ensureField(accumulators, path);
|
|
271
|
+
const acc = accumulators.get(path);
|
|
272
|
+
acc.types.add(classifyValue(value));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function ensureField(accumulators, path) {
|
|
276
|
+
if (!accumulators.has(path)) {
|
|
277
|
+
accumulators.set(path, {
|
|
278
|
+
path,
|
|
279
|
+
presentCount: 0,
|
|
280
|
+
types: new Set(),
|
|
281
|
+
distinctValues: new Set(),
|
|
282
|
+
schemaSeeded: false,
|
|
283
|
+
approvedSource: false,
|
|
284
|
+
approvedTarget: false,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function touchFieldObservation(acc, value) {
|
|
289
|
+
if (value === null || value === undefined)
|
|
290
|
+
return;
|
|
291
|
+
if (!isScalarJoinable(value))
|
|
292
|
+
return;
|
|
293
|
+
acc.presentCount += 1;
|
|
294
|
+
acc.types.add(classifyValue(value));
|
|
295
|
+
const normalized = normalizeScalarValue(value);
|
|
296
|
+
if (normalized !== null && acc.distinctValues.size < 500) {
|
|
297
|
+
acc.distinctValues.add(normalized);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
export function isScalarJoinable(value) {
|
|
301
|
+
if (value === null || value === undefined)
|
|
302
|
+
return false;
|
|
303
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
|
|
304
|
+
return true;
|
|
305
|
+
if (value instanceof Date)
|
|
306
|
+
return true;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
export function normalizeScalarValue(value) {
|
|
310
|
+
if (value === null || value === undefined)
|
|
311
|
+
return null;
|
|
312
|
+
if (typeof value === 'string') {
|
|
313
|
+
const trimmed = value.trim();
|
|
314
|
+
return trimmed.length ? trimmed : null;
|
|
315
|
+
}
|
|
316
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
317
|
+
return String(value);
|
|
318
|
+
if (value instanceof Date)
|
|
319
|
+
return value.toISOString();
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
export function classifyValue(value) {
|
|
323
|
+
if (value === null || value === undefined)
|
|
324
|
+
return 'mixed';
|
|
325
|
+
if (typeof value === 'string')
|
|
326
|
+
return isDateLike(value) ? 'date' : 'string';
|
|
327
|
+
if (typeof value === 'number')
|
|
328
|
+
return 'number';
|
|
329
|
+
if (typeof value === 'boolean')
|
|
330
|
+
return 'boolean';
|
|
331
|
+
if (value instanceof Date)
|
|
332
|
+
return 'date';
|
|
333
|
+
if (Array.isArray(value))
|
|
334
|
+
return 'array';
|
|
335
|
+
if (typeof value === 'object')
|
|
336
|
+
return 'object';
|
|
337
|
+
return 'mixed';
|
|
338
|
+
}
|
|
339
|
+
function mergeInferredTypes(types) {
|
|
340
|
+
const filtered = [...types].filter((t) => t !== 'mixed');
|
|
341
|
+
if (filtered.length === 0)
|
|
342
|
+
return 'mixed';
|
|
343
|
+
const unique = new Set(filtered);
|
|
344
|
+
if (unique.size === 1)
|
|
345
|
+
return [...unique][0];
|
|
346
|
+
if (unique.has('string') && (unique.has('number') || unique.has('boolean')))
|
|
347
|
+
return 'mixed';
|
|
348
|
+
return 'mixed';
|
|
349
|
+
}
|
|
350
|
+
function isJoinSafeType(type) {
|
|
351
|
+
return type === 'string' || type === 'number' || type === 'boolean' || type === 'date';
|
|
352
|
+
}
|
|
353
|
+
function applySchemaSeeds(sourceFields, targetFields, schemaPairs, minCoverage) {
|
|
354
|
+
for (const pair of schemaPairs) {
|
|
355
|
+
ensureInventoryField(sourceFields, pair.sourcePath, minCoverage, true);
|
|
356
|
+
ensureInventoryField(targetFields, pair.targetPath, minCoverage, true);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function ensureInventoryField(fields, path, minCoverage, schemaSeeded) {
|
|
360
|
+
const existing = fields.find((f) => f.path === path);
|
|
361
|
+
if (existing) {
|
|
362
|
+
existing.schemaSeeded = schemaSeeded;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
fields.push({
|
|
366
|
+
path,
|
|
367
|
+
inferredType: 'string',
|
|
368
|
+
coverage: minCoverage,
|
|
369
|
+
distinctValueCount: 0,
|
|
370
|
+
uniqueness: 0,
|
|
371
|
+
nonNullCount: 0,
|
|
372
|
+
distinctValues: new Set(),
|
|
373
|
+
schemaSeeded,
|
|
374
|
+
approvedSource: false,
|
|
375
|
+
approvedTarget: false,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function markApprovedFields(sourceFields, targetFields, seeds) {
|
|
379
|
+
if (seeds.approvedJoin) {
|
|
380
|
+
const source = sourceFields.find((f) => f.path === seeds.approvedJoin.sourcePath);
|
|
381
|
+
const target = targetFields.find((f) => f.path === seeds.approvedJoin.targetPath);
|
|
382
|
+
if (source)
|
|
383
|
+
source.approvedSource = true;
|
|
384
|
+
if (target)
|
|
385
|
+
target.approvedTarget = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
function probeJoinPairs(params) {
|
|
389
|
+
const { sourceFields, targetFields, sourceObjectType, targetObjectType, seeds, distinctCap, maxPairs, minCoverage, } = params;
|
|
390
|
+
let sourceCandidates = [...sourceFields];
|
|
391
|
+
let targetCandidates = [...targetFields];
|
|
392
|
+
if (sourceCandidates.length * targetCandidates.length > maxPairs) {
|
|
393
|
+
const k = Math.max(2, Math.floor(Math.sqrt(maxPairs)));
|
|
394
|
+
sourceCandidates = prefilterFields(sourceCandidates, 'source', targetObjectType, seeds, k);
|
|
395
|
+
targetCandidates = prefilterFields(targetCandidates, 'target', sourceObjectType, seeds, k);
|
|
396
|
+
}
|
|
397
|
+
const targetValueSets = new Map();
|
|
398
|
+
for (const field of targetCandidates) {
|
|
399
|
+
targetValueSets.set(field.path, field.distinctValues);
|
|
400
|
+
}
|
|
401
|
+
const probes = [];
|
|
402
|
+
for (const source of sourceCandidates) {
|
|
403
|
+
const sourceValues = [...source.distinctValues].slice(0, distinctCap);
|
|
404
|
+
if (sourceValues.length === 0 && !source.schemaSeeded && source.coverage < 0.2)
|
|
405
|
+
continue;
|
|
406
|
+
for (const target of targetCandidates) {
|
|
407
|
+
const targetValues = targetValueSets.get(target.path) ?? new Set();
|
|
408
|
+
const valueHitRate = computeValueHitRate(sourceValues, targetValues);
|
|
409
|
+
const schemaRelationBoost = seeds.schemaPairs.some((p) => p.sourcePath === source.path && p.targetPath === target.path)
|
|
410
|
+
? 1
|
|
411
|
+
: 0;
|
|
412
|
+
const approvedLinkBoost = seeds.approvedJoin?.sourcePath === source.path && seeds.approvedJoin?.targetPath === target.path
|
|
413
|
+
? 1
|
|
414
|
+
: seeds.simulatedJoin?.sourcePath === source.path && seeds.simulatedJoin?.targetPath === target.path
|
|
415
|
+
? 0.5
|
|
416
|
+
: 0;
|
|
417
|
+
probes.push({
|
|
418
|
+
sourcePath: source.path,
|
|
419
|
+
targetPath: target.path,
|
|
420
|
+
valueHitRate,
|
|
421
|
+
sourceCoverage: source.coverage,
|
|
422
|
+
targetCoverage: target.coverage,
|
|
423
|
+
targetUniqueness: target.uniqueness,
|
|
424
|
+
nameSimilarity: computeNameSimilarity(source.path, target.path, targetObjectType),
|
|
425
|
+
schemaRelationBoost,
|
|
426
|
+
approvedLinkBoost,
|
|
427
|
+
sourceType: source.inferredType,
|
|
428
|
+
targetType: target.inferredType,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return probes.filter((probe) => {
|
|
433
|
+
if (probe.schemaRelationBoost > 0 || probe.approvedLinkBoost > 0)
|
|
434
|
+
return true;
|
|
435
|
+
if (probe.sourceCoverage < minCoverage)
|
|
436
|
+
return false;
|
|
437
|
+
if (probe.sourceCoverage < 0.2)
|
|
438
|
+
return false;
|
|
439
|
+
return true;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function prefilterFields(fields, side, otherObjectType, seeds, limit) {
|
|
443
|
+
const scored = fields.map((field) => {
|
|
444
|
+
let score = field.coverage * 0.4;
|
|
445
|
+
score += side === 'source'
|
|
446
|
+
? sourceNameHintScore(field.path, otherObjectType) * 0.3
|
|
447
|
+
: targetNameHintScore(field.path, otherObjectType) * 0.3;
|
|
448
|
+
if (field.schemaSeeded)
|
|
449
|
+
score += 0.2;
|
|
450
|
+
if (field.approvedSource || field.approvedTarget)
|
|
451
|
+
score += 0.1;
|
|
452
|
+
if (side === 'target')
|
|
453
|
+
score += field.uniqueness * 0.2;
|
|
454
|
+
return { field, score };
|
|
455
|
+
});
|
|
456
|
+
scored.sort((a, b) => b.score - a.score || a.field.path.localeCompare(b.field.path));
|
|
457
|
+
return scored.slice(0, limit).map((row) => row.field);
|
|
458
|
+
}
|
|
459
|
+
export function computeValueHitRate(sourceValues, targetValues) {
|
|
460
|
+
if (sourceValues.length === 0 || targetValues.size === 0)
|
|
461
|
+
return 0;
|
|
462
|
+
let hits = 0;
|
|
463
|
+
for (const value of sourceValues) {
|
|
464
|
+
if (targetValues.has(value))
|
|
465
|
+
hits += 1;
|
|
466
|
+
}
|
|
467
|
+
return hits / sourceValues.length;
|
|
468
|
+
}
|
|
469
|
+
export function computeNameSimilarity(sourcePath, targetPath, targetObjectType) {
|
|
470
|
+
const sourceLeaf = leafName(sourcePath).toLowerCase();
|
|
471
|
+
const targetLeaf = leafName(targetPath).toLowerCase();
|
|
472
|
+
const targetType = targetObjectType.toLowerCase();
|
|
473
|
+
const targetStem = entityStem(targetType);
|
|
474
|
+
if (sourceLeaf === targetLeaf)
|
|
475
|
+
return 1;
|
|
476
|
+
if (sourceLeaf === `${targetType}id` || sourceLeaf === `${targetType}_id`)
|
|
477
|
+
return 0.95;
|
|
478
|
+
if (sourceLeaf === `${targetStem}id` || sourceLeaf === `${targetStem}_id`)
|
|
479
|
+
return 0.95;
|
|
480
|
+
if (sourceLeaf.endsWith('id') && (sourceLeaf.includes(targetType) || sourceLeaf.includes(targetStem))) {
|
|
481
|
+
return 0.85;
|
|
482
|
+
}
|
|
483
|
+
if (targetLeaf === 'id' || targetLeaf.endsWith('id'))
|
|
484
|
+
return 0.7;
|
|
485
|
+
if (ID_SUFFIX_RE.test(leafName(sourcePath)) || ID_SUFFIX_RE.test(leafName(targetPath)))
|
|
486
|
+
return 0.55;
|
|
487
|
+
return 0;
|
|
488
|
+
}
|
|
489
|
+
function leafName(path) {
|
|
490
|
+
const segments = path.split('.');
|
|
491
|
+
return segments[segments.length - 1] ?? path;
|
|
492
|
+
}
|
|
493
|
+
function entityStem(name) {
|
|
494
|
+
const lower = name.toLowerCase();
|
|
495
|
+
return lower.endsWith('s') && lower.length > 1 ? lower.slice(0, -1) : lower;
|
|
496
|
+
}
|
|
497
|
+
export function sourceNameHintScore(path, targetObjectType) {
|
|
498
|
+
const leaf = leafName(path);
|
|
499
|
+
const lower = leaf.toLowerCase();
|
|
500
|
+
const target = targetObjectType.toLowerCase();
|
|
501
|
+
const targetStem = entityStem(target);
|
|
502
|
+
if (lower === `${target}id` || lower === `${target}_id`)
|
|
503
|
+
return 1;
|
|
504
|
+
if (lower === `${targetStem}id` || lower === `${targetStem}_id`)
|
|
505
|
+
return 0.95;
|
|
506
|
+
if (lower.endsWith('id') && (lower.includes(target) || lower.includes(targetStem)))
|
|
507
|
+
return 0.85;
|
|
508
|
+
if (ID_SUFFIX_RE.test(leaf))
|
|
509
|
+
return 0.55;
|
|
510
|
+
return 0;
|
|
511
|
+
}
|
|
512
|
+
export function targetNameHintScore(path, sourceObjectType) {
|
|
513
|
+
const leaf = leafName(path);
|
|
514
|
+
const lower = leaf.toLowerCase();
|
|
515
|
+
if (lower === 'id')
|
|
516
|
+
return 1;
|
|
517
|
+
if (lower.endsWith('id'))
|
|
518
|
+
return 0.75;
|
|
519
|
+
const sourceLeaf = sourceObjectType.toLowerCase();
|
|
520
|
+
if (lower.includes(sourceLeaf))
|
|
521
|
+
return 0.5;
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
export function computePairScore(probe) {
|
|
525
|
+
let score = 0.55 * probe.valueHitRate
|
|
526
|
+
+ 0.15 * Math.min(probe.sourceCoverage, probe.targetCoverage)
|
|
527
|
+
+ 0.15 * probe.targetUniqueness
|
|
528
|
+
+ 0.10 * probe.nameSimilarity
|
|
529
|
+
+ probe.schemaRelationBoost * 0.15
|
|
530
|
+
+ probe.approvedLinkBoost * 0.20;
|
|
531
|
+
if (typesMismatch(probe.sourceType, probe.targetType)) {
|
|
532
|
+
score *= 0.3;
|
|
533
|
+
}
|
|
534
|
+
if (probe.targetUniqueness < 0.5) {
|
|
535
|
+
score *= 0.6;
|
|
536
|
+
}
|
|
537
|
+
return clamp01(score);
|
|
538
|
+
}
|
|
539
|
+
function typesMismatch(sourceType, targetType) {
|
|
540
|
+
if (sourceType === targetType)
|
|
541
|
+
return false;
|
|
542
|
+
if ((sourceType === 'string' && targetType === 'number')
|
|
543
|
+
|| (sourceType === 'number' && targetType === 'string')) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
return sourceType !== 'mixed' && targetType !== 'mixed' && sourceType !== targetType;
|
|
547
|
+
}
|
|
548
|
+
function clamp01(value) {
|
|
549
|
+
if (value < 0)
|
|
550
|
+
return 0;
|
|
551
|
+
if (value > 1)
|
|
552
|
+
return 1;
|
|
553
|
+
return value;
|
|
554
|
+
}
|
|
555
|
+
function rankSourceFields(inventory, probes, targetObjectType, seeds) {
|
|
556
|
+
const bestHitBySource = new Map();
|
|
557
|
+
for (const probe of probes) {
|
|
558
|
+
const current = bestHitBySource.get(probe.sourcePath) ?? 0;
|
|
559
|
+
bestHitBySource.set(probe.sourcePath, Math.max(current, probe.valueHitRate));
|
|
560
|
+
}
|
|
561
|
+
const scored = inventory.map((field) => {
|
|
562
|
+
const bestHit = bestHitBySource.get(field.path) ?? 0;
|
|
563
|
+
const score = clamp01(0.50 * bestHit
|
|
564
|
+
+ 0.15 * field.coverage
|
|
565
|
+
+ 0.15 * sourceNameHintScore(field.path, targetObjectType)
|
|
566
|
+
+ (field.schemaSeeded ? 0.10 : 0)
|
|
567
|
+
+ (field.approvedSource ? 0.10 : 0));
|
|
568
|
+
const reasons = buildSourceReasons(field, bestHit, targetObjectType, seeds);
|
|
569
|
+
return { field, score, reasons };
|
|
570
|
+
});
|
|
571
|
+
scored.sort((a, b) => b.score - a.score || a.field.path.localeCompare(b.field.path));
|
|
572
|
+
return scored.map((row, index) => ({
|
|
573
|
+
path: row.field.path,
|
|
574
|
+
inferredType: row.field.inferredType,
|
|
575
|
+
rank: index + 1,
|
|
576
|
+
score: row.score,
|
|
577
|
+
coverage: row.field.coverage,
|
|
578
|
+
distinctValueCount: row.field.distinctValueCount,
|
|
579
|
+
reasons: row.reasons,
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
function rankTargetFields(inventory, probes, sourceObjectType, seeds) {
|
|
583
|
+
const bestHitByTarget = new Map();
|
|
584
|
+
for (const probe of probes) {
|
|
585
|
+
const current = bestHitByTarget.get(probe.targetPath) ?? 0;
|
|
586
|
+
bestHitByTarget.set(probe.targetPath, Math.max(current, probe.valueHitRate));
|
|
587
|
+
}
|
|
588
|
+
const scored = inventory.map((field) => {
|
|
589
|
+
const bestHit = bestHitByTarget.get(field.path) ?? 0;
|
|
590
|
+
const score = clamp01(0.45 * bestHit
|
|
591
|
+
+ 0.25 * field.uniqueness
|
|
592
|
+
+ 0.10 * field.coverage
|
|
593
|
+
+ 0.10 * targetNameHintScore(field.path, sourceObjectType)
|
|
594
|
+
+ (field.schemaSeeded ? 0.10 : 0));
|
|
595
|
+
const reasons = buildTargetReasons(field, bestHit, seeds);
|
|
596
|
+
return { field, score, reasons };
|
|
597
|
+
});
|
|
598
|
+
scored.sort((a, b) => b.score - a.score || a.field.path.localeCompare(b.field.path));
|
|
599
|
+
return scored.map((row, index) => ({
|
|
600
|
+
path: row.field.path,
|
|
601
|
+
inferredType: row.field.inferredType,
|
|
602
|
+
rank: index + 1,
|
|
603
|
+
score: row.score,
|
|
604
|
+
coverage: row.field.coverage,
|
|
605
|
+
distinctValueCount: row.field.distinctValueCount,
|
|
606
|
+
uniqueness: row.field.uniqueness,
|
|
607
|
+
reasons: row.reasons,
|
|
608
|
+
}));
|
|
609
|
+
}
|
|
610
|
+
function buildSourceReasons(field, bestHit, targetObjectType, seeds) {
|
|
611
|
+
const reasons = [];
|
|
612
|
+
if (bestHit >= 0.5)
|
|
613
|
+
reasons.push('high_value_hit_rate');
|
|
614
|
+
if (sourceNameHintScore(field.path, targetObjectType) >= 0.55) {
|
|
615
|
+
reasons.push('name_contains_target_type');
|
|
616
|
+
}
|
|
617
|
+
if (field.approvedSource)
|
|
618
|
+
reasons.push('approved_link_history');
|
|
619
|
+
if (field.schemaSeeded)
|
|
620
|
+
reasons.push('schema_relation');
|
|
621
|
+
if (seeds.schemaPairs.some((pair) => pair.sourcePath === field.path)
|
|
622
|
+
&& !reasons.includes('schema_relation')) {
|
|
623
|
+
reasons.push('schema_relation');
|
|
624
|
+
}
|
|
625
|
+
return reasons;
|
|
626
|
+
}
|
|
627
|
+
function buildTargetReasons(field, bestHit, seeds) {
|
|
628
|
+
const reasons = [];
|
|
629
|
+
if (leafName(field.path).toLowerCase() === 'id')
|
|
630
|
+
reasons.push('primary_identifier');
|
|
631
|
+
if (bestHit >= 0.5)
|
|
632
|
+
reasons.push('high_value_hit_rate');
|
|
633
|
+
if (field.uniqueness >= 0.95)
|
|
634
|
+
reasons.push('near_unique_values');
|
|
635
|
+
if (field.schemaSeeded)
|
|
636
|
+
reasons.push('schema_relation');
|
|
637
|
+
if (seeds.approvedJoin?.targetPath === field.path
|
|
638
|
+
&& !reasons.includes('approved_link_history')) {
|
|
639
|
+
reasons.push('approved_link_history');
|
|
640
|
+
}
|
|
641
|
+
return reasons;
|
|
642
|
+
}
|
|
643
|
+
function rankJoinCandidates(params) {
|
|
644
|
+
const candidates = params.probes
|
|
645
|
+
.map((probe) => {
|
|
646
|
+
const score = computePairScore(probe);
|
|
647
|
+
return { probe, score };
|
|
648
|
+
})
|
|
649
|
+
.filter((row) => row.score >= params.minPairScore)
|
|
650
|
+
.sort((a, b) => {
|
|
651
|
+
if (params.approvedJoin) {
|
|
652
|
+
const aApproved = a.probe.sourcePath === params.approvedJoin.sourcePath
|
|
653
|
+
&& a.probe.targetPath === params.approvedJoin.targetPath;
|
|
654
|
+
const bApproved = b.probe.sourcePath === params.approvedJoin.sourcePath
|
|
655
|
+
&& b.probe.targetPath === params.approvedJoin.targetPath;
|
|
656
|
+
if (aApproved !== bApproved)
|
|
657
|
+
return aApproved ? -1 : 1;
|
|
658
|
+
}
|
|
659
|
+
return b.score - a.score
|
|
660
|
+
|| a.probe.sourcePath.localeCompare(b.probe.sourcePath)
|
|
661
|
+
|| a.probe.targetPath.localeCompare(b.probe.targetPath);
|
|
662
|
+
})
|
|
663
|
+
.slice(0, params.maxCandidates);
|
|
664
|
+
return candidates.map((row, index) => ({
|
|
665
|
+
rank: index + 1,
|
|
666
|
+
score: row.score,
|
|
667
|
+
sourcePath: row.probe.sourcePath,
|
|
668
|
+
targetPath: row.probe.targetPath,
|
|
669
|
+
operator: 'eq',
|
|
670
|
+
signals: {
|
|
671
|
+
valueHitRate: row.probe.valueHitRate,
|
|
672
|
+
sourceCoverage: row.probe.sourceCoverage,
|
|
673
|
+
targetUniqueness: row.probe.targetUniqueness,
|
|
674
|
+
nameSimilarity: row.probe.nameSimilarity,
|
|
675
|
+
schemaRelationBoost: row.probe.schemaRelationBoost,
|
|
676
|
+
approvedLinkBoost: row.probe.approvedLinkBoost >= 1 ? 1 : row.probe.approvedLinkBoost,
|
|
677
|
+
},
|
|
678
|
+
summary: buildCandidateSummary(row.probe),
|
|
679
|
+
}));
|
|
680
|
+
}
|
|
681
|
+
function buildCandidateSummary(probe) {
|
|
682
|
+
const hitPct = Math.round(probe.valueHitRate * 100);
|
|
683
|
+
if (probe.approvedLinkBoost >= 1) {
|
|
684
|
+
return 'Previously approved Hippox link for this object-type pair.';
|
|
685
|
+
}
|
|
686
|
+
if (probe.valueHitRate >= 0.5) {
|
|
687
|
+
const uniquenessNote = probe.targetUniqueness >= 0.95
|
|
688
|
+
? 'target values are unique.'
|
|
689
|
+
: 'target values may not be unique.';
|
|
690
|
+
return `${hitPct}% of sampled source values appear in target field; ${uniquenessNote}`;
|
|
691
|
+
}
|
|
692
|
+
if (probe.schemaRelationBoost > 0) {
|
|
693
|
+
return 'Declared in Memorix schema relations for this object-type pair.';
|
|
694
|
+
}
|
|
695
|
+
return `${hitPct}% value overlap between sampled source and target fields.`;
|
|
696
|
+
}
|
|
697
|
+
//# sourceMappingURL=join-discovery.js.map
|