@duckcodeailabs/dql-core 1.6.34 → 1.7.1
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/apps/app-document.d.ts +3 -1
- package/dist/apps/app-document.d.ts.map +1 -1
- package/dist/apps/app-document.js +26 -11
- package/dist/apps/app-document.js.map +1 -1
- package/dist/apps/dashboard-document.d.ts +74 -0
- package/dist/apps/dashboard-document.d.ts.map +1 -1
- package/dist/apps/dashboard-document.js +101 -2
- package/dist/apps/dashboard-document.js.map +1 -1
- package/dist/apps/index.d.ts +3 -1
- package/dist/apps/index.d.ts.map +1 -1
- package/dist/apps/index.js +1 -0
- package/dist/apps/index.js.map +1 -1
- package/dist/apps/product-domain-context.d.ts +23 -0
- package/dist/apps/product-domain-context.d.ts.map +1 -0
- package/dist/apps/product-domain-context.js +25 -0
- package/dist/apps/product-domain-context.js.map +1 -0
- package/dist/artifacts/dql-artifact.d.ts +13 -0
- package/dist/artifacts/dql-artifact.d.ts.map +1 -1
- package/dist/artifacts/dql-artifact.js +69 -0
- package/dist/artifacts/dql-artifact.js.map +1 -1
- package/dist/artifacts/index.d.ts +1 -1
- package/dist/artifacts/index.d.ts.map +1 -1
- package/dist/ast/index.d.ts +1 -1
- package/dist/ast/index.d.ts.map +1 -1
- package/dist/ast/index.js.map +1 -1
- package/dist/ast/nodes.d.ts +10 -1
- package/dist/ast/nodes.d.ts.map +1 -1
- package/dist/ast/printer.js +4 -0
- package/dist/ast/printer.js.map +1 -1
- package/dist/blocks/index.d.ts +2 -0
- package/dist/blocks/index.d.ts.map +1 -0
- package/dist/blocks/index.js +2 -0
- package/dist/blocks/index.js.map +1 -0
- package/dist/blocks/parameters.d.ts +55 -0
- package/dist/blocks/parameters.d.ts.map +1 -0
- package/dist/blocks/parameters.js +160 -0
- package/dist/blocks/parameters.js.map +1 -0
- package/dist/format/diff.d.ts.map +1 -1
- package/dist/format/diff.js +19 -11
- package/dist/format/diff.js.map +1 -1
- package/dist/format/notebook.d.ts +1 -1
- package/dist/format/notebook.js +2 -2
- package/dist/formatter/formatter.js +23 -1
- package/dist/formatter/formatter.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manifest/builder.d.ts +17 -0
- package/dist/manifest/builder.d.ts.map +1 -1
- package/dist/manifest/builder.js +143 -9
- package/dist/manifest/builder.js.map +1 -1
- package/dist/manifest/datalex-migration.d.ts +68 -0
- package/dist/manifest/datalex-migration.d.ts.map +1 -0
- package/dist/manifest/datalex-migration.js +369 -0
- package/dist/manifest/datalex-migration.js.map +1 -0
- package/dist/manifest/dbt-first-authoring.d.ts +210 -0
- package/dist/manifest/dbt-first-authoring.d.ts.map +1 -0
- package/dist/manifest/dbt-first-authoring.js +510 -0
- package/dist/manifest/dbt-first-authoring.js.map +1 -0
- package/dist/manifest/dbt-first-modeling.d.ts +34 -0
- package/dist/manifest/dbt-first-modeling.d.ts.map +1 -0
- package/dist/manifest/dbt-first-modeling.js +931 -0
- package/dist/manifest/dbt-first-modeling.js.map +1 -0
- package/dist/manifest/domain-discovery.d.ts +98 -0
- package/dist/manifest/domain-discovery.d.ts.map +1 -0
- package/dist/manifest/domain-discovery.js +503 -0
- package/dist/manifest/domain-discovery.js.map +1 -0
- package/dist/manifest/domain-package-registry.d.ts +20 -0
- package/dist/manifest/domain-package-registry.d.ts.map +1 -0
- package/dist/manifest/domain-package-registry.js +265 -0
- package/dist/manifest/domain-package-registry.js.map +1 -0
- package/dist/manifest/domain-writer.d.ts +4 -0
- package/dist/manifest/domain-writer.d.ts.map +1 -1
- package/dist/manifest/domain-writer.js +3 -0
- package/dist/manifest/domain-writer.js.map +1 -1
- package/dist/manifest/index.d.ts +7 -1
- package/dist/manifest/index.d.ts.map +1 -1
- package/dist/manifest/index.js +6 -0
- package/dist/manifest/index.js.map +1 -1
- package/dist/manifest/project-snapshot.d.ts +30 -0
- package/dist/manifest/project-snapshot.d.ts.map +1 -0
- package/dist/manifest/project-snapshot.js +68 -0
- package/dist/manifest/project-snapshot.js.map +1 -0
- package/dist/manifest/types.d.ts +289 -3
- package/dist/manifest/types.d.ts.map +1 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +40 -6
- package/dist/parser/parser.js.map +1 -1
- package/dist/semantic/analyzer.js +1 -1
- package/dist/semantic/analyzer.js.map +1 -1
- package/dist/semantic/providers/repo-resolver.d.ts.map +1 -1
- package/dist/semantic/providers/repo-resolver.js +14 -4
- package/dist/semantic/providers/repo-resolver.js.map +1 -1
- package/dist/semantic/semantic-layer.d.ts +6 -0
- package/dist/semantic/semantic-layer.d.ts.map +1 -1
- package/dist/semantic/semantic-layer.js +68 -2
- package/dist/semantic/semantic-layer.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dbt-first domain modeling (manifest v3).
|
|
3
|
+
*
|
|
4
|
+
* The loader deliberately reads dbt artifacts as the physical source of truth
|
|
5
|
+
* and emits only stable references and DQL-owned analytical policy. Do not add
|
|
6
|
+
* copied dbt descriptions, column catalogs, tests, or MetricFlow formulas here.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { dirname, extname, join, relative, sep } from 'node:path';
|
|
11
|
+
import * as yaml from 'js-yaml';
|
|
12
|
+
import { loadDomainPackageRegistry } from './domain-package-registry.js';
|
|
13
|
+
/** Returns the sibling dbt artifact only when it exists. */
|
|
14
|
+
export function siblingDbtArtifact(manifestPath, name) {
|
|
15
|
+
const candidate = join(dirname(manifestPath), name);
|
|
16
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build v3 provenance and sparse packages from a dbt manifest. This function
|
|
20
|
+
* is intentionally deterministic: it uses source content and paths only, not
|
|
21
|
+
* wall-clock time.
|
|
22
|
+
*/
|
|
23
|
+
export function loadDbtFirstModeling(projectRoot, manifestPath) {
|
|
24
|
+
const diagnostics = [];
|
|
25
|
+
const rawManifest = readJson(manifestPath, 'dbt manifest', diagnostics);
|
|
26
|
+
const catalogPath = siblingDbtArtifact(manifestPath, 'catalog.json');
|
|
27
|
+
const semanticManifestPath = siblingDbtArtifact(manifestPath, 'semantic_manifest.json');
|
|
28
|
+
const rawCatalog = catalogPath ? readJson(catalogPath, 'dbt catalog', diagnostics) : undefined;
|
|
29
|
+
const rawSemantic = semanticManifestPath
|
|
30
|
+
? readJson(semanticManifestPath, 'dbt semantic manifest', diagnostics)
|
|
31
|
+
: undefined;
|
|
32
|
+
const nodeFacts = new Map();
|
|
33
|
+
const nodeProvenance = {};
|
|
34
|
+
const rawNodes = asRecord(rawManifest?.nodes);
|
|
35
|
+
const rawSources = asRecord(rawManifest?.sources);
|
|
36
|
+
for (const [uniqueId, node] of [...Object.entries(rawNodes), ...Object.entries(rawSources)]) {
|
|
37
|
+
const raw = asRecord(node);
|
|
38
|
+
const resourceType = raw.resource_type === 'model' ? 'model' : raw.resource_type === 'source' ? 'source' : undefined;
|
|
39
|
+
if (!resourceType)
|
|
40
|
+
continue;
|
|
41
|
+
const name = stringValue(raw.alias) ?? stringValue(raw.identifier) ?? stringValue(raw.name);
|
|
42
|
+
if (!name)
|
|
43
|
+
continue;
|
|
44
|
+
const sourcePath = stringValue(raw.original_file_path) ?? stringValue(raw.path);
|
|
45
|
+
const relation = relationName(raw, name);
|
|
46
|
+
const dqlMeta = asRecord(asRecord(raw.meta).dql);
|
|
47
|
+
const keys = stringArray(dqlMeta.keys ?? dqlMeta.key ?? dqlMeta.primary_key);
|
|
48
|
+
const grain = stringValue(dqlMeta.grain);
|
|
49
|
+
const columns = new Set(Object.keys(asRecord(raw.columns)).map((column) => column.toLowerCase()));
|
|
50
|
+
const catalogEntry = catalogNode(rawCatalog, uniqueId);
|
|
51
|
+
const identityFingerprint = fingerprint({
|
|
52
|
+
uniqueId,
|
|
53
|
+
relation,
|
|
54
|
+
grain,
|
|
55
|
+
keys: [...keys].sort(),
|
|
56
|
+
columns: [...columns].sort(),
|
|
57
|
+
dqlMeta,
|
|
58
|
+
});
|
|
59
|
+
nodeFacts.set(uniqueId, {
|
|
60
|
+
uniqueId,
|
|
61
|
+
resourceType,
|
|
62
|
+
name,
|
|
63
|
+
packageName: stringValue(raw.package_name),
|
|
64
|
+
relation,
|
|
65
|
+
sourcePath,
|
|
66
|
+
grain,
|
|
67
|
+
keys,
|
|
68
|
+
columns,
|
|
69
|
+
identityFingerprint,
|
|
70
|
+
});
|
|
71
|
+
nodeProvenance[uniqueId] = {
|
|
72
|
+
uniqueId,
|
|
73
|
+
resourceType,
|
|
74
|
+
name,
|
|
75
|
+
packageName: stringValue(raw.package_name),
|
|
76
|
+
relation,
|
|
77
|
+
sourcePath,
|
|
78
|
+
identityFingerprint,
|
|
79
|
+
available: {
|
|
80
|
+
description: Boolean(stringValue(raw.description)),
|
|
81
|
+
columns: columns.size > 0,
|
|
82
|
+
tests: hasDbtTests(raw),
|
|
83
|
+
catalogTypes: hasCatalogTypes(catalogEntry),
|
|
84
|
+
dqlMeta: Object.keys(dqlMeta).length > 0,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const metricFlow = collectMetricFlowProvenance(rawManifest, rawSemantic);
|
|
89
|
+
const provenance = {
|
|
90
|
+
manifestPath,
|
|
91
|
+
catalogPath,
|
|
92
|
+
semanticManifestPath,
|
|
93
|
+
manifestFingerprint: fileFingerprint(manifestPath),
|
|
94
|
+
catalogFingerprint: catalogPath ? fileFingerprint(catalogPath) : undefined,
|
|
95
|
+
semanticManifestFingerprint: semanticManifestPath ? fileFingerprint(semanticManifestPath) : undefined,
|
|
96
|
+
projectName: stringValue(asRecord(rawManifest?.metadata).project_name),
|
|
97
|
+
nodes: sortRecord(nodeProvenance),
|
|
98
|
+
metricFlow: sortRecord(metricFlow),
|
|
99
|
+
};
|
|
100
|
+
const domainSources = loadDomainSources(projectRoot, diagnostics);
|
|
101
|
+
const modelFiles = collectModelingFiles(domainSources);
|
|
102
|
+
const areas = {};
|
|
103
|
+
const entities = {};
|
|
104
|
+
const rawRelationships = [];
|
|
105
|
+
const contracts = {};
|
|
106
|
+
const conformance = {};
|
|
107
|
+
const rules = {};
|
|
108
|
+
const domainExports = {};
|
|
109
|
+
const domainImports = {};
|
|
110
|
+
for (const filePath of modelFiles) {
|
|
111
|
+
const source = readYaml(filePath, diagnostics);
|
|
112
|
+
if (!source)
|
|
113
|
+
continue;
|
|
114
|
+
const packageSource = packageForFile(domainSources, filePath);
|
|
115
|
+
const domain = stringValue(source.domain) ?? packageSource?.id;
|
|
116
|
+
const relPath = relative(projectRoot, filePath).replace(/\\/g, '/');
|
|
117
|
+
if (!domain) {
|
|
118
|
+
diagnostics.push(modelingError(relPath, 'modeling source is not inside a Domain Package and does not declare `domain`'));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const rawArea = asRecord(source.area);
|
|
122
|
+
const areaLocalId = stringValue(rawArea.id);
|
|
123
|
+
let area;
|
|
124
|
+
if (areaLocalId) {
|
|
125
|
+
const qualifiedId = qualifiedObjectId(domain, 'model_area', areaLocalId);
|
|
126
|
+
area = {
|
|
127
|
+
id: qualifiedId,
|
|
128
|
+
localId: areaLocalId,
|
|
129
|
+
qualifiedId,
|
|
130
|
+
domain,
|
|
131
|
+
name: stringValue(rawArea.name) ?? areaLocalId,
|
|
132
|
+
description: stringValue(rawArea.description),
|
|
133
|
+
intentExamples: stringArray(rawArea.intent_examples ?? rawArea.intentExamples),
|
|
134
|
+
entityIds: [],
|
|
135
|
+
relationshipIds: [],
|
|
136
|
+
referencedEntityIds: stringArray(rawArea.references),
|
|
137
|
+
sourcePath: relPath,
|
|
138
|
+
};
|
|
139
|
+
if (!insertScopedRecord(areas, area)) {
|
|
140
|
+
diagnostics.push(modelingError(relPath, `duplicate model area "${area.qualifiedId}" in the same Domain Package`));
|
|
141
|
+
area = undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (Object.keys(rawArea).length > 0) {
|
|
145
|
+
diagnostics.push(modelingError(relPath, 'model area requires `area.id`'));
|
|
146
|
+
}
|
|
147
|
+
for (const rawEntity of arrayOfRecords(source.entities)) {
|
|
148
|
+
const id = stringValue(rawEntity.id);
|
|
149
|
+
const dbtUniqueId = stringValue(rawEntity.dbt_model) ?? stringValue(rawEntity.dbtModel);
|
|
150
|
+
if (!id || !dbtUniqueId) {
|
|
151
|
+
diagnostics.push(modelingError(relPath, 'each entity requires `id` and `dbt_model`'));
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const dbt = nodeFacts.get(dbtUniqueId);
|
|
155
|
+
if (!dbt || dbt.resourceType !== 'model') {
|
|
156
|
+
diagnostics.push(modelingError(relPath, `entity "${id}" references unknown dbt model "${dbtUniqueId}"`));
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const explicitGrain = stringValue(rawEntity.grain);
|
|
160
|
+
const explicitKeys = stringArray(rawEntity.keys);
|
|
161
|
+
const grain = explicitGrain ?? dbt.grain;
|
|
162
|
+
const keys = explicitKeys.length > 0 ? explicitKeys : dbt.keys;
|
|
163
|
+
const entityDomain = stringValue(rawEntity.domain) ?? domain;
|
|
164
|
+
if (area && entityDomain !== domain) {
|
|
165
|
+
diagnostics.push(modelingError(relPath, `entity "${id}" in model area "${areaLocalId}" must belong to domain "${domain}"`));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const qualifiedId = qualifiedObjectId(entityDomain, 'entity', id);
|
|
169
|
+
const entity = {
|
|
170
|
+
id: qualifiedId,
|
|
171
|
+
localId: id,
|
|
172
|
+
qualifiedId,
|
|
173
|
+
domain: entityDomain,
|
|
174
|
+
areaId: area?.qualifiedId,
|
|
175
|
+
dbtUniqueId,
|
|
176
|
+
businessName: stringValue(rawEntity.business_name ?? rawEntity.businessName),
|
|
177
|
+
businessContext: stringValue(rawEntity.business_context ?? rawEntity.businessContext),
|
|
178
|
+
grain,
|
|
179
|
+
keys,
|
|
180
|
+
analyticalRole: analyticalRole(rawEntity.analytical_role ?? rawEntity.analyticalRole),
|
|
181
|
+
conceptRefs: stringArray(rawEntity.concept_refs ?? rawEntity.conceptRefs),
|
|
182
|
+
owner: stringValue(rawEntity.owner),
|
|
183
|
+
status: lifecycle(rawEntity.status),
|
|
184
|
+
sourcePath: relPath,
|
|
185
|
+
identityFingerprint: fingerprint({
|
|
186
|
+
dbt: dbt.identityFingerprint,
|
|
187
|
+
grain,
|
|
188
|
+
keys: [...keys].sort(),
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
if (!insertScopedRecord(entities, entity)) {
|
|
192
|
+
diagnostics.push(modelingError(relPath, `duplicate entity "${entity.qualifiedId}" in the same Domain Package`));
|
|
193
|
+
}
|
|
194
|
+
else if (area) {
|
|
195
|
+
area.entityIds.push(entity.qualifiedId);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const rawRelationship of arrayOfRecords(source.relationships)) {
|
|
199
|
+
rawRelationships.push({ value: rawRelationship, sourcePath: relPath, domain, areaId: area?.qualifiedId });
|
|
200
|
+
}
|
|
201
|
+
for (const rawContract of arrayOfRecords(source.contracts)) {
|
|
202
|
+
const id = stringValue(rawContract.id);
|
|
203
|
+
if (!id) {
|
|
204
|
+
diagnostics.push(modelingError(relPath, 'each contract requires `id`'));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const contractDomain = stringValue(rawContract.domain) ?? domain;
|
|
208
|
+
const qualifiedId = qualifiedObjectId(contractDomain, 'contract', id);
|
|
209
|
+
const contract = {
|
|
210
|
+
id: qualifiedId,
|
|
211
|
+
localId: id,
|
|
212
|
+
qualifiedId,
|
|
213
|
+
domain: contractDomain,
|
|
214
|
+
entities: stringArray(rawContract.entities),
|
|
215
|
+
blocks: stringArray(rawContract.blocks),
|
|
216
|
+
status: lifecycle(rawContract.status),
|
|
217
|
+
owner: stringValue(rawContract.owner),
|
|
218
|
+
sourcePath: relPath,
|
|
219
|
+
requiredEvaluation: rawContract.required_evaluation !== false && rawContract.requiredEvaluation !== false,
|
|
220
|
+
version: positiveInteger(rawContract.version, 1),
|
|
221
|
+
grain: stringValue(rawContract.grain),
|
|
222
|
+
metricRefs: stringArray(rawContract.metrics ?? rawContract.metric_refs ?? rawContract.metricRefs),
|
|
223
|
+
dimensions: stringArray(rawContract.dimensions),
|
|
224
|
+
allowedFilters: stringArray(rawContract.allowed_filters ?? rawContract.allowedFilters),
|
|
225
|
+
requiredFilters: stringArray(rawContract.required_filters ?? rawContract.requiredFilters),
|
|
226
|
+
purpose: stringValue(rawContract.purpose),
|
|
227
|
+
evaluationRefs: stringArray(rawContract.evaluations ?? rawContract.evaluation_refs ?? rawContract.evaluationRefs),
|
|
228
|
+
};
|
|
229
|
+
if (!insertScopedRecord(contracts, contract))
|
|
230
|
+
diagnostics.push(modelingError(relPath, `duplicate contract "${contract.qualifiedId}" in the same Domain Package`));
|
|
231
|
+
}
|
|
232
|
+
for (const rawDeclaration of arrayOfRecords(source.conformance)) {
|
|
233
|
+
const id = stringValue(rawDeclaration.id);
|
|
234
|
+
const rule = stringValue(rawDeclaration.rule);
|
|
235
|
+
if (!id || !rule) {
|
|
236
|
+
diagnostics.push(modelingError(relPath, 'each conformance declaration requires `id` and `rule`'));
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const qualifiedId = qualifiedObjectId(domain, 'conformance', id);
|
|
240
|
+
const declaration = {
|
|
241
|
+
id: qualifiedId,
|
|
242
|
+
localId: id,
|
|
243
|
+
qualifiedId,
|
|
244
|
+
domain,
|
|
245
|
+
entities: stringArray(rawDeclaration.entities),
|
|
246
|
+
rule,
|
|
247
|
+
sourcePath: relPath,
|
|
248
|
+
};
|
|
249
|
+
if (!insertScopedRecord(conformance, declaration))
|
|
250
|
+
diagnostics.push(modelingError(relPath, `duplicate conformance declaration "${declaration.qualifiedId}" in the same Domain Package`));
|
|
251
|
+
}
|
|
252
|
+
for (const rawRule of arrayOfRecords(source.rules)) {
|
|
253
|
+
const id = stringValue(rawRule.id);
|
|
254
|
+
const expression = stringValue(rawRule.expression);
|
|
255
|
+
if (!id || !expression) {
|
|
256
|
+
diagnostics.push(modelingError(relPath, 'each rule requires `id` and `expression`'));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const ruleDomain = stringValue(rawRule.domain) ?? domain;
|
|
260
|
+
const qualifiedId = qualifiedObjectId(ruleDomain, 'rule', id);
|
|
261
|
+
const ruleValue = {
|
|
262
|
+
id: qualifiedId,
|
|
263
|
+
localId: id,
|
|
264
|
+
qualifiedId,
|
|
265
|
+
domain: ruleDomain,
|
|
266
|
+
kind: ruleKind(rawRule.kind),
|
|
267
|
+
expression,
|
|
268
|
+
sourcePath: relPath,
|
|
269
|
+
};
|
|
270
|
+
if (!insertScopedRecord(rules, ruleValue))
|
|
271
|
+
diagnostics.push(modelingError(relPath, `duplicate rule "${ruleValue.qualifiedId}" in the same Domain Package`));
|
|
272
|
+
}
|
|
273
|
+
for (const rawExport of arrayOfRecords(source.exports)) {
|
|
274
|
+
const localId = stringValue(rawExport.id);
|
|
275
|
+
if (!localId) {
|
|
276
|
+
diagnostics.push(modelingError(relPath, 'each domain export requires `id`'));
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const version = positiveInteger(rawExport.version, 1);
|
|
280
|
+
const id = localId.includes('.') ? localId : `${domain}.${localId}`;
|
|
281
|
+
const ref = `${id}@${version}`;
|
|
282
|
+
if (domainExports[ref]) {
|
|
283
|
+
diagnostics.push(modelingError(relPath, `duplicate domain export "${ref}"`));
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const exportDomain = stringValue(rawExport.domain) ?? domain;
|
|
287
|
+
const qualifiedId = qualifiedObjectId(exportDomain, 'export', localId);
|
|
288
|
+
const value = {
|
|
289
|
+
id: qualifiedId,
|
|
290
|
+
localId,
|
|
291
|
+
qualifiedId,
|
|
292
|
+
domain: exportDomain,
|
|
293
|
+
version,
|
|
294
|
+
entity: stringValue(rawExport.entity),
|
|
295
|
+
metrics: stringArray(rawExport.metrics ?? rawExport.allowed_metrics ?? rawExport.allowedMetrics),
|
|
296
|
+
blocks: stringArray(rawExport.blocks),
|
|
297
|
+
allowedKeys: stringArray(rawExport.allowed_keys ?? rawExport.allowedKeys),
|
|
298
|
+
allowedDimensions: stringArray(rawExport.allowed_dimensions ?? rawExport.allowedDimensions),
|
|
299
|
+
allowedFilters: stringArray(rawExport.allowed_filters ?? rawExport.allowedFilters),
|
|
300
|
+
purposes: stringArray(rawExport.purposes),
|
|
301
|
+
consumerDomains: stringArray(rawExport.consumer_domains ?? rawExport.consumerDomains),
|
|
302
|
+
classification: stringValue(rawExport.classification),
|
|
303
|
+
contract: stringValue(rawExport.contract),
|
|
304
|
+
status: lifecycle(rawExport.status),
|
|
305
|
+
owner: stringValue(rawExport.owner),
|
|
306
|
+
sourcePath: relPath,
|
|
307
|
+
fingerprint: '',
|
|
308
|
+
};
|
|
309
|
+
value.fingerprint = fingerprint(value);
|
|
310
|
+
domainExports[ref] = value;
|
|
311
|
+
}
|
|
312
|
+
for (const rawImport of arrayOfRecords(source.imports)) {
|
|
313
|
+
const exportRef = stringValue(rawImport.export ?? rawImport.export_ref ?? rawImport.exportRef);
|
|
314
|
+
if (!exportRef) {
|
|
315
|
+
diagnostics.push(modelingError(relPath, 'each domain import requires `export`'));
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const localId = stringValue(rawImport.id) ?? exportRef.replace(/[^A-Za-z0-9_-]+/g, '_');
|
|
319
|
+
const importDomain = stringValue(rawImport.domain ?? rawImport.consumer_domain ?? rawImport.consumerDomain) ?? domain;
|
|
320
|
+
const qualifiedId = qualifiedObjectId(importDomain, 'import', localId);
|
|
321
|
+
if (domainImports[qualifiedId]) {
|
|
322
|
+
diagnostics.push(modelingError(relPath, `duplicate domain import "${localId}"`));
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
domainImports[qualifiedId] = {
|
|
326
|
+
id: qualifiedId,
|
|
327
|
+
localId,
|
|
328
|
+
qualifiedId,
|
|
329
|
+
domain: importDomain,
|
|
330
|
+
exportRef,
|
|
331
|
+
purpose: stringValue(rawImport.purpose) ?? '',
|
|
332
|
+
status: lifecycle(rawImport.status),
|
|
333
|
+
owner: stringValue(rawImport.owner),
|
|
334
|
+
sourcePath: relPath,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
validateInterfaces(domainExports, domainImports, entities, diagnostics);
|
|
339
|
+
validateContracts(contracts, entities, diagnostics);
|
|
340
|
+
const relationships = buildRelationships(rawRelationships, entities, nodeFacts, domainSources, domainExports, domainImports, contracts, diagnostics);
|
|
341
|
+
for (const relationship of Object.values(relationships)) {
|
|
342
|
+
if (relationship.areaId && areas[relationship.areaId])
|
|
343
|
+
areas[relationship.areaId].relationshipIds.push(relationship.qualifiedId);
|
|
344
|
+
}
|
|
345
|
+
for (const area of Object.values(areas)) {
|
|
346
|
+
const references = area.referencedEntityIds.flatMap((reference) => {
|
|
347
|
+
const resolved = resolveScopedKey(entities, reference, area.domain);
|
|
348
|
+
if (resolved)
|
|
349
|
+
return [resolved];
|
|
350
|
+
diagnostics.push(modelingError(area.sourcePath, `model area "${area.localId}" references unknown entity "${reference}"`));
|
|
351
|
+
return [];
|
|
352
|
+
});
|
|
353
|
+
area.entityIds.sort();
|
|
354
|
+
area.relationshipIds.sort();
|
|
355
|
+
area.referencedEntityIds = [...new Set(references)].sort();
|
|
356
|
+
}
|
|
357
|
+
validateConformance(conformance, entities, diagnostics);
|
|
358
|
+
const packages = Object.fromEntries([...domainSources.values()]
|
|
359
|
+
.map((source) => [source.id, {
|
|
360
|
+
id: source.id,
|
|
361
|
+
filePath: relative(projectRoot, source.filePath).replace(/\\/g, '/'),
|
|
362
|
+
parent: source.parent,
|
|
363
|
+
exports: source.exports,
|
|
364
|
+
owner: source.owner,
|
|
365
|
+
}])
|
|
366
|
+
.sort(([a], [b]) => a.localeCompare(b)));
|
|
367
|
+
const domainLineage = Object.values(relationships)
|
|
368
|
+
.filter((relationship) => entities[relationship.from] && entities[relationship.to])
|
|
369
|
+
.map((relationship) => ({
|
|
370
|
+
relationship: relationship.qualifiedId,
|
|
371
|
+
fromDomain: entities[relationship.from].domain,
|
|
372
|
+
toDomain: entities[relationship.to].domain,
|
|
373
|
+
automaticJoinAllowed: relationship.automaticJoinAllowed,
|
|
374
|
+
staleCertification: relationship.staleCertification,
|
|
375
|
+
}))
|
|
376
|
+
.sort((a, b) => a.relationship.localeCompare(b.relationship));
|
|
377
|
+
return {
|
|
378
|
+
provenance,
|
|
379
|
+
modeling: {
|
|
380
|
+
mode: 'dbt-first',
|
|
381
|
+
packages,
|
|
382
|
+
areas: sortRecord(areas),
|
|
383
|
+
entities: sortRecord(entities),
|
|
384
|
+
relationships: sortRecord(relationships),
|
|
385
|
+
contracts: sortRecord(contracts),
|
|
386
|
+
conformance: sortRecord(conformance),
|
|
387
|
+
rules: sortRecord(rules),
|
|
388
|
+
interfaces: {
|
|
389
|
+
exports: sortRecord(domainExports),
|
|
390
|
+
imports: sortRecord(domainImports),
|
|
391
|
+
},
|
|
392
|
+
domainLineage,
|
|
393
|
+
},
|
|
394
|
+
diagnostics,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
function buildRelationships(rawRelationships, entities, nodeFacts, packages, domainExports, domainImports, contracts, diagnostics) {
|
|
398
|
+
const relationships = {};
|
|
399
|
+
for (const { value, sourcePath, domain, areaId } of rawRelationships) {
|
|
400
|
+
const id = stringValue(value.id);
|
|
401
|
+
const from = stringValue(value.from);
|
|
402
|
+
const to = stringValue(value.to);
|
|
403
|
+
if (!id || !from || !to) {
|
|
404
|
+
diagnostics.push(modelingError(sourcePath, 'each relationship requires `id`, `from`, and `to`'));
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const fromKey = resolveScopedKey(entities, from, domain);
|
|
408
|
+
const toKey = resolveScopedKey(entities, to, domain);
|
|
409
|
+
const fromEntity = fromKey ? entities[fromKey] : undefined;
|
|
410
|
+
const toEntity = toKey ? entities[toKey] : undefined;
|
|
411
|
+
if (!fromEntity || !toEntity) {
|
|
412
|
+
diagnostics.push(modelingError(sourcePath, `relationship "${id}" references unknown entity "${!fromEntity ? from : toEntity ? to : `${from}" and "${to}`}`));
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const keys = relationshipKeys(value.keys);
|
|
416
|
+
if (keys.length === 0) {
|
|
417
|
+
diagnostics.push(modelingError(sourcePath, `relationship "${id}" requires one or more key pairs`));
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const fromFacts = nodeFacts.get(fromEntity.dbtUniqueId);
|
|
421
|
+
const toFacts = nodeFacts.get(toEntity.dbtUniqueId);
|
|
422
|
+
let keyColumnsValid = true;
|
|
423
|
+
for (const pair of keys) {
|
|
424
|
+
if (fromFacts && !fromFacts.columns.has(pair.from.toLowerCase())) {
|
|
425
|
+
keyColumnsValid = false;
|
|
426
|
+
diagnostics.push(modelingError(sourcePath, `relationship "${id}" key "${pair.from}" is not a column of ${fromEntity.dbtUniqueId}`));
|
|
427
|
+
}
|
|
428
|
+
if (toFacts && !toFacts.columns.has(pair.to.toLowerCase())) {
|
|
429
|
+
keyColumnsValid = false;
|
|
430
|
+
diagnostics.push(modelingError(sourcePath, `relationship "${id}" key "${pair.to}" is not a column of ${toEntity.dbtUniqueId}`));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const cardinality = relationshipCardinality(value.cardinality);
|
|
434
|
+
const fanout = fanoutPolicy(value.fanout);
|
|
435
|
+
const status = lifecycle(value.status);
|
|
436
|
+
const crossDomain = value.crossDomain === true || fromEntity.domain !== toEntity.domain;
|
|
437
|
+
const ownerDomain = stringValue(value.owner_domain ?? value.ownerDomain) ?? domain;
|
|
438
|
+
const importRefs = stringArray(value.imports);
|
|
439
|
+
const structuredInterfaces = Object.keys(domainExports).length > 0 || Object.keys(domainImports).length > 0;
|
|
440
|
+
const interfacesGranted = !crossDomain || (structuredInterfaces
|
|
441
|
+
? crossDomainInterfacesGranted(ownerDomain, [fromEntity, toEntity], importRefs, domainExports, domainImports, sourcePath, id, diagnostics)
|
|
442
|
+
: packages.get(fromEntity.domain)?.exports.includes(from) === true);
|
|
443
|
+
const contractsGranted = !crossDomain || (structuredInterfaces
|
|
444
|
+
&& crossDomainContractsGranted(ownerDomain, importRefs, domainExports, domainImports, contracts, sourcePath, id, diagnostics));
|
|
445
|
+
const certificationFingerprint = certificationProof(value.certifiedAgainst, keys, cardinality, fanout);
|
|
446
|
+
const validation = validationEvidence(value.validation);
|
|
447
|
+
const validationProof = relationshipValidationProofFingerprint({
|
|
448
|
+
fromRelation: fromFacts?.relation,
|
|
449
|
+
toRelation: toFacts?.relation,
|
|
450
|
+
keys,
|
|
451
|
+
cardinality,
|
|
452
|
+
fanout,
|
|
453
|
+
queryFingerprint: validation?.queryFingerprint ?? '',
|
|
454
|
+
});
|
|
455
|
+
const validationMatches = Boolean(validation?.proofFingerprint && validation.proofFingerprint === validationProof);
|
|
456
|
+
const evidenceExpiresAt = stringValue(value.evidence_expires_at ?? value.evidenceExpiresAt);
|
|
457
|
+
const evidenceExpired = Boolean(evidenceExpiresAt && Number.isFinite(Date.parse(evidenceExpiresAt)) && Date.parse(evidenceExpiresAt) <= Date.now());
|
|
458
|
+
const currentProof = fingerprint({
|
|
459
|
+
from: { grain: fromEntity.grain, keys: [...fromEntity.keys].sort(), identity: fromEntity.identityFingerprint },
|
|
460
|
+
to: { grain: toEntity.grain, keys: [...toEntity.keys].sort(), identity: toEntity.identityFingerprint },
|
|
461
|
+
relationshipKeys: keys,
|
|
462
|
+
cardinality,
|
|
463
|
+
fanout,
|
|
464
|
+
});
|
|
465
|
+
const currentCertificationProof = fingerprint({
|
|
466
|
+
from: { grain: fromEntity.grain, keys: [...fromEntity.keys].sort() },
|
|
467
|
+
to: { grain: toEntity.grain, keys: [...toEntity.keys].sort() },
|
|
468
|
+
relationshipKeys: keys,
|
|
469
|
+
cardinality,
|
|
470
|
+
fanout,
|
|
471
|
+
});
|
|
472
|
+
const staleCertification = status === 'certified' && (!certificationFingerprint || certificationFingerprint !== currentCertificationProof);
|
|
473
|
+
if (status === 'certified' && !certificationFingerprint) {
|
|
474
|
+
diagnostics.push(modelingError(sourcePath, `certified relationship "${id}" requires ` + '`certifiedAgainst` so DQL can detect dbt identity drift'));
|
|
475
|
+
}
|
|
476
|
+
else if (staleCertification) {
|
|
477
|
+
diagnostics.push({
|
|
478
|
+
kind: 'modeling',
|
|
479
|
+
filePath: sourcePath,
|
|
480
|
+
severity: 'warning',
|
|
481
|
+
message: `certification for relationship "${id}" is stale because its dbt identity/grain proof changed`,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (crossDomain && !interfacesGranted && !structuredInterfaces) {
|
|
485
|
+
diagnostics.push(modelingError(sourcePath, `cross-domain relationship "${id}" requires ${fromEntity.domain} to export entity "${from}"`));
|
|
486
|
+
}
|
|
487
|
+
const automaticJoinAllowed = status === 'certified'
|
|
488
|
+
&& !staleCertification
|
|
489
|
+
&& keyColumnsValid
|
|
490
|
+
&& !evidenceExpired
|
|
491
|
+
&& interfacesGranted
|
|
492
|
+
&& contractsGranted
|
|
493
|
+
&& validation?.status === 'passed'
|
|
494
|
+
&& validationMatches
|
|
495
|
+
&& (cardinality === 'many_to_one' || cardinality === 'one_to_many' || cardinality === 'one_to_one')
|
|
496
|
+
&& fanout === 'safe';
|
|
497
|
+
if (status === 'certified' && validation?.status !== 'passed') {
|
|
498
|
+
diagnostics.push({
|
|
499
|
+
kind: 'modeling',
|
|
500
|
+
filePath: sourcePath,
|
|
501
|
+
severity: 'warning',
|
|
502
|
+
message: `certified relationship "${id}" has no passed warehouse validation evidence and cannot prove an automatic join`,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
if (status === 'certified' && evidenceExpired) {
|
|
506
|
+
diagnostics.push({ kind: 'modeling', filePath: sourcePath, severity: 'warning', message: `relationship "${id}" validation evidence expired at ${evidenceExpiresAt}` });
|
|
507
|
+
}
|
|
508
|
+
if (status === 'certified' && !validationMatches) {
|
|
509
|
+
diagnostics.push({ kind: 'modeling', filePath: sourcePath, severity: 'warning', message: `relationship "${id}" validation proof no longer matches its relations, keys, cardinality, or fanout policy` });
|
|
510
|
+
}
|
|
511
|
+
const qualifiedId = qualifiedObjectId(ownerDomain, 'relationship', id);
|
|
512
|
+
const relationship = {
|
|
513
|
+
id: qualifiedId,
|
|
514
|
+
localId: id,
|
|
515
|
+
qualifiedId,
|
|
516
|
+
areaId,
|
|
517
|
+
from: fromKey,
|
|
518
|
+
to: toKey,
|
|
519
|
+
keys,
|
|
520
|
+
cardinality,
|
|
521
|
+
fanout,
|
|
522
|
+
status,
|
|
523
|
+
crossDomain,
|
|
524
|
+
ownerDomain,
|
|
525
|
+
owner: stringValue(value.owner),
|
|
526
|
+
verb: stringValue(value.verb),
|
|
527
|
+
description: stringValue(value.description),
|
|
528
|
+
rationale: stringValue(value.rationale),
|
|
529
|
+
roles: relationshipRoles(value.roles),
|
|
530
|
+
optionality: relationshipOptionality(value.optionality),
|
|
531
|
+
joinTypes: relationshipJoinTypes(value.join_types ?? value.joinTypes),
|
|
532
|
+
aggregation: relationshipAggregation(value.aggregation),
|
|
533
|
+
temporal: relationshipTemporal(value.temporal),
|
|
534
|
+
attributionBlock: stringValue(value.attribution_block ?? value.attributionBlock),
|
|
535
|
+
importRefs,
|
|
536
|
+
evidenceExpiresAt,
|
|
537
|
+
sourcePath,
|
|
538
|
+
fingerprint: currentProof,
|
|
539
|
+
certificationFingerprint,
|
|
540
|
+
validation,
|
|
541
|
+
staleCertification,
|
|
542
|
+
automaticJoinAllowed,
|
|
543
|
+
};
|
|
544
|
+
if (!insertScopedRecord(relationships, relationship)) {
|
|
545
|
+
diagnostics.push(modelingError(sourcePath, `duplicate relationship "${relationship.qualifiedId}" in the same Domain Package`));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return relationships;
|
|
549
|
+
}
|
|
550
|
+
function validationEvidence(value) {
|
|
551
|
+
const raw = asRecord(value);
|
|
552
|
+
const status = raw.status === 'passed' || raw.status === 'failed' || raw.status === 'error' ? raw.status : undefined;
|
|
553
|
+
const checkedAt = stringValue(raw.checked_at ?? raw.checkedAt);
|
|
554
|
+
const queryFingerprint = stringValue(raw.query_fingerprint ?? raw.queryFingerprint);
|
|
555
|
+
const proofFingerprint = stringValue(raw.proof_fingerprint ?? raw.proofFingerprint);
|
|
556
|
+
if (!status || !checkedAt || !queryFingerprint)
|
|
557
|
+
return undefined;
|
|
558
|
+
const numberValue = (input) => typeof input === 'number' && Number.isFinite(input) ? input : Number(input) || 0;
|
|
559
|
+
return {
|
|
560
|
+
status,
|
|
561
|
+
checkedAt,
|
|
562
|
+
queryFingerprint,
|
|
563
|
+
proofFingerprint,
|
|
564
|
+
fromRows: numberValue(raw.from_rows ?? raw.fromRows),
|
|
565
|
+
toRows: numberValue(raw.to_rows ?? raw.toRows),
|
|
566
|
+
joinedRows: numberValue(raw.joined_rows ?? raw.joinedRows),
|
|
567
|
+
fromNullKeys: numberValue(raw.from_null_keys ?? raw.fromNullKeys),
|
|
568
|
+
toNullKeys: numberValue(raw.to_null_keys ?? raw.toNullKeys),
|
|
569
|
+
unmatchedFrom: numberValue(raw.unmatched_from ?? raw.unmatchedFrom),
|
|
570
|
+
maxFromPerKey: numberValue(raw.max_from_per_key ?? raw.maxFromPerKey),
|
|
571
|
+
maxToPerKey: numberValue(raw.max_to_per_key ?? raw.maxToPerKey),
|
|
572
|
+
message: stringValue(raw.message),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
function qualifiedObjectId(domain, kind, localId) {
|
|
576
|
+
const prefix = `${domain}::${kind}::`;
|
|
577
|
+
return localId.startsWith(prefix) ? localId : `${prefix}${localId}`;
|
|
578
|
+
}
|
|
579
|
+
export function relationshipValidationProofFingerprint(input) {
|
|
580
|
+
return fingerprint({
|
|
581
|
+
fromRelation: input.fromRelation,
|
|
582
|
+
toRelation: input.toRelation,
|
|
583
|
+
keys: input.keys,
|
|
584
|
+
cardinality: input.cardinality,
|
|
585
|
+
fanout: input.fanout,
|
|
586
|
+
queryFingerprint: input.queryFingerprint,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Manifest v3 always uses canonical qualified keys. Source-local ids are kept
|
|
591
|
+
* only for display and package-local authoring references.
|
|
592
|
+
*/
|
|
593
|
+
function insertScopedRecord(record, value) {
|
|
594
|
+
if (record[value.qualifiedId])
|
|
595
|
+
return false;
|
|
596
|
+
record[value.qualifiedId] = value;
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
function resolveScopedKey(record, reference, ownerDomain) {
|
|
600
|
+
if (record[reference])
|
|
601
|
+
return reference;
|
|
602
|
+
if (ownerDomain) {
|
|
603
|
+
const kind = Object.values(record)[0]?.qualifiedId.split('::')[1];
|
|
604
|
+
const localQualified = kind ? qualifiedObjectId(ownerDomain, kind, reference) : undefined;
|
|
605
|
+
if (localQualified && record[localQualified])
|
|
606
|
+
return localQualified;
|
|
607
|
+
}
|
|
608
|
+
const matches = Object.entries(record).filter(([, item]) => item.localId === reference
|
|
609
|
+
|| item.qualifiedId === reference
|
|
610
|
+
|| `${item.domain ?? item.ownerDomain}:${item.localId}` === reference);
|
|
611
|
+
return matches.length === 1 ? matches[0][0] : undefined;
|
|
612
|
+
}
|
|
613
|
+
function certificationProof(value, relationshipKeys, cardinality, fanout) {
|
|
614
|
+
if (!asRecord(value) || Object.keys(asRecord(value)).length === 0)
|
|
615
|
+
return undefined;
|
|
616
|
+
const from = asRecord(asRecord(value).from);
|
|
617
|
+
const to = asRecord(asRecord(value).to);
|
|
618
|
+
const fromGrain = stringValue(from.grain);
|
|
619
|
+
const toGrain = stringValue(to.grain);
|
|
620
|
+
const fromKeys = stringArray(from.keys);
|
|
621
|
+
const toKeys = stringArray(to.keys);
|
|
622
|
+
if (!fromGrain || !toGrain || fromKeys.length === 0 || toKeys.length === 0)
|
|
623
|
+
return undefined;
|
|
624
|
+
return fingerprint({
|
|
625
|
+
from: { grain: fromGrain, keys: fromKeys.sort() },
|
|
626
|
+
to: { grain: toGrain, keys: toKeys.sort() },
|
|
627
|
+
relationshipKeys,
|
|
628
|
+
cardinality,
|
|
629
|
+
fanout,
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
function validateInterfaces(exports, imports, entities, diagnostics) {
|
|
633
|
+
for (const value of Object.values(exports)) {
|
|
634
|
+
if (value.entity) {
|
|
635
|
+
const key = resolveScopedKey(entities, value.entity, value.domain);
|
|
636
|
+
if (key)
|
|
637
|
+
value.entity = key;
|
|
638
|
+
else
|
|
639
|
+
diagnostics.push(modelingError(value.sourcePath, `domain export "${value.id}@${value.version}" references unknown or ambiguous entity "${value.entity}"`));
|
|
640
|
+
}
|
|
641
|
+
if (value.status === 'certified' && !value.owner) {
|
|
642
|
+
diagnostics.push(modelingError(value.sourcePath, `certified domain export "${value.id}@${value.version}" requires an owner`));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
for (const value of Object.values(imports)) {
|
|
646
|
+
const exported = exports[value.exportRef];
|
|
647
|
+
if (!exported) {
|
|
648
|
+
diagnostics.push(modelingError(value.sourcePath, `domain import "${value.id}" references unknown export "${value.exportRef}"`));
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (exported.consumerDomains.length > 0 && !exported.consumerDomains.includes(value.domain)) {
|
|
652
|
+
diagnostics.push(modelingError(value.sourcePath, `domain import "${value.id}" is not an allowed consumer of "${value.exportRef}"`));
|
|
653
|
+
}
|
|
654
|
+
if (exported.purposes.length > 0 && !exported.purposes.includes(value.purpose)) {
|
|
655
|
+
diagnostics.push(modelingError(value.sourcePath, `domain import "${value.id}" purpose "${value.purpose}" is not allowed by "${value.exportRef}"`));
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function crossDomainInterfacesGranted(ownerDomain, endpoints, importRefs, exports, imports, sourcePath, relationshipId, diagnostics) {
|
|
660
|
+
let granted = true;
|
|
661
|
+
for (const endpoint of endpoints.filter((entity) => entity.domain !== ownerDomain)) {
|
|
662
|
+
const matching = importRefs
|
|
663
|
+
.map((ref) => Object.values(imports).find((value) => value.exportRef === ref && value.domain === ownerDomain))
|
|
664
|
+
.filter((value) => Boolean(value))
|
|
665
|
+
.find((value) => {
|
|
666
|
+
const exported = exports[value.exportRef];
|
|
667
|
+
return exported?.domain === endpoint.domain && (!exported.entity || [endpoint.id, endpoint.localId, endpoint.qualifiedId].includes(exported.entity));
|
|
668
|
+
});
|
|
669
|
+
const exported = matching ? exports[matching.exportRef] : undefined;
|
|
670
|
+
if (!matching || matching.status !== 'certified' || !exported || exported.status !== 'certified') {
|
|
671
|
+
diagnostics.push(modelingError(sourcePath, `cross-domain relationship "${relationshipId}" requires a certified ${ownerDomain} import of a certified ${endpoint.domain} export for entity "${endpoint.id}"`));
|
|
672
|
+
granted = false;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return granted;
|
|
676
|
+
}
|
|
677
|
+
function crossDomainContractsGranted(ownerDomain, importRefs, exports, imports, contracts, sourcePath, relationshipId, diagnostics) {
|
|
678
|
+
let granted = true;
|
|
679
|
+
for (const exportRef of importRefs) {
|
|
680
|
+
const imported = Object.values(imports).find((value) => value.domain === ownerDomain && value.exportRef === exportRef && value.status === 'certified');
|
|
681
|
+
const exported = exports[exportRef];
|
|
682
|
+
const contract = exported?.contract
|
|
683
|
+
? Object.values(contracts).find((value) => value.qualifiedId === exported.contract
|
|
684
|
+
|| value.id === exported.contract
|
|
685
|
+
|| (value.domain === exported.domain && value.localId === exported.contract))
|
|
686
|
+
: undefined;
|
|
687
|
+
const compatible = Boolean(imported?.purpose
|
|
688
|
+
&& exported?.status === 'certified'
|
|
689
|
+
&& contract?.status === 'certified'
|
|
690
|
+
&& (!contract.purpose || contract.purpose === imported.purpose)
|
|
691
|
+
&& (!exported.entity || contract.entities.includes(exported.entity)));
|
|
692
|
+
if (!compatible) {
|
|
693
|
+
diagnostics.push(modelingError(sourcePath, `cross-domain relationship "${relationshipId}" requires export "${exportRef}" to have a compatible certified contract and explicit import purpose`));
|
|
694
|
+
granted = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return granted && importRefs.length > 0;
|
|
698
|
+
}
|
|
699
|
+
function relationshipRoles(value) {
|
|
700
|
+
const raw = asRecord(value);
|
|
701
|
+
const from = stringValue(raw.from);
|
|
702
|
+
const to = stringValue(raw.to);
|
|
703
|
+
return from || to ? { from, to } : undefined;
|
|
704
|
+
}
|
|
705
|
+
function relationshipOptionality(value) {
|
|
706
|
+
const raw = asRecord(value);
|
|
707
|
+
if (Object.keys(raw).length === 0)
|
|
708
|
+
return undefined;
|
|
709
|
+
const normalize = (input) => input === 'required' || input === 'optional' ? input : 'unknown';
|
|
710
|
+
return { from: normalize(raw.from), to: normalize(raw.to) };
|
|
711
|
+
}
|
|
712
|
+
function relationshipJoinTypes(value) {
|
|
713
|
+
const values = stringArray(value).filter((item) => item === 'left' || item === 'inner');
|
|
714
|
+
return values.length > 0 ? [...new Set(values)] : undefined;
|
|
715
|
+
}
|
|
716
|
+
function relationshipAggregation(value) {
|
|
717
|
+
const raw = asRecord(value);
|
|
718
|
+
if (Object.keys(raw).length === 0)
|
|
719
|
+
return undefined;
|
|
720
|
+
return {
|
|
721
|
+
measuresFrom: stringArray(raw.measures_from ?? raw.measuresFrom),
|
|
722
|
+
dimensionsFrom: stringArray(raw.dimensions_from ?? raw.dimensionsFrom),
|
|
723
|
+
requiresPreAggregation: raw.requires_pre_aggregation === true || raw.requiresPreAggregation === true || undefined,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
function relationshipTemporal(value) {
|
|
727
|
+
const raw = asRecord(value);
|
|
728
|
+
const factTime = stringValue(raw.fact_time ?? raw.factTime);
|
|
729
|
+
const validFrom = stringValue(raw.valid_from ?? raw.validFrom);
|
|
730
|
+
if (!factTime || !validFrom)
|
|
731
|
+
return undefined;
|
|
732
|
+
return {
|
|
733
|
+
factTime,
|
|
734
|
+
validFrom,
|
|
735
|
+
validTo: stringValue(raw.valid_to ?? raw.validTo),
|
|
736
|
+
openEnded: raw.open_ended === true || raw.openEnded === true || undefined,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
function validateContracts(contracts, entities, diagnostics) {
|
|
740
|
+
for (const contract of Object.values(contracts)) {
|
|
741
|
+
contract.entities = contract.entities.flatMap((entity) => {
|
|
742
|
+
const key = resolveScopedKey(entities, entity, contract.domain);
|
|
743
|
+
if (key)
|
|
744
|
+
return [key];
|
|
745
|
+
diagnostics.push(modelingError(contract.sourcePath, `contract "${contract.id}" references unknown or ambiguous entity "${entity}"`));
|
|
746
|
+
return [];
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
function validateConformance(declarations, entities, diagnostics) {
|
|
751
|
+
for (const declaration of Object.values(declarations)) {
|
|
752
|
+
if (declaration.entities.length < 2) {
|
|
753
|
+
diagnostics.push(modelingError(declaration.sourcePath, `conformance "${declaration.id}" must name at least two entities`));
|
|
754
|
+
}
|
|
755
|
+
declaration.entities = declaration.entities.flatMap((entity) => {
|
|
756
|
+
const key = resolveScopedKey(entities, entity, declaration.domain);
|
|
757
|
+
if (key)
|
|
758
|
+
return [key];
|
|
759
|
+
diagnostics.push(modelingError(declaration.sourcePath, `conformance "${declaration.id}" references unknown or ambiguous entity "${entity}"`));
|
|
760
|
+
return [];
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function loadDomainSources(projectRoot, diagnostics) {
|
|
765
|
+
const registry = loadDomainPackageRegistry(projectRoot);
|
|
766
|
+
diagnostics.push(...registry.diagnostics);
|
|
767
|
+
return new Map(registry.values().map((pkg) => [pkg.id, {
|
|
768
|
+
id: pkg.id,
|
|
769
|
+
root: pkg.root,
|
|
770
|
+
filePath: join(projectRoot, pkg.declarationPath),
|
|
771
|
+
parent: pkg.parent,
|
|
772
|
+
exports: pkg.exports,
|
|
773
|
+
owner: pkg.owner,
|
|
774
|
+
}]));
|
|
775
|
+
}
|
|
776
|
+
function collectModelingFiles(packages) {
|
|
777
|
+
return [...new Set([...packages.values()].flatMap((pkg) => scanYaml(join(pkg.root, 'modeling'))))].sort();
|
|
778
|
+
}
|
|
779
|
+
function packageForFile(packages, filePath) {
|
|
780
|
+
return [...packages.values()]
|
|
781
|
+
.filter((pkg) => filePath.startsWith(`${pkg.root}${sep}`))
|
|
782
|
+
.sort((a, b) => b.root.length - a.root.length)[0];
|
|
783
|
+
}
|
|
784
|
+
function collectMetricFlowProvenance(rawManifest, rawSemantic) {
|
|
785
|
+
const metrics = {};
|
|
786
|
+
for (const [uniqueId, raw] of Object.entries(asRecord(rawManifest?.metrics))) {
|
|
787
|
+
const metric = asRecord(raw);
|
|
788
|
+
const name = stringValue(metric.name);
|
|
789
|
+
if (!name)
|
|
790
|
+
continue;
|
|
791
|
+
metrics[uniqueId] = {
|
|
792
|
+
uniqueId,
|
|
793
|
+
name,
|
|
794
|
+
sourcePath: stringValue(metric.original_file_path) ?? stringValue(metric.path),
|
|
795
|
+
semanticModel: stringValue(asRecord(metric.type_params).semantic_model),
|
|
796
|
+
fingerprint: fingerprint({ uniqueId, name, semanticModel: asRecord(metric.type_params).semantic_model }),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
for (const [uniqueId, raw] of Object.entries(asRecord(rawSemantic?.metrics))) {
|
|
800
|
+
const metric = asRecord(raw);
|
|
801
|
+
const name = stringValue(metric.name) ?? uniqueId;
|
|
802
|
+
if (metrics[uniqueId])
|
|
803
|
+
continue;
|
|
804
|
+
metrics[uniqueId] = {
|
|
805
|
+
uniqueId,
|
|
806
|
+
name,
|
|
807
|
+
sourcePath: stringValue(metric.path),
|
|
808
|
+
semanticModel: stringValue(metric.semantic_model),
|
|
809
|
+
fingerprint: fingerprint({ uniqueId, name, semanticModel: metric.semantic_model }),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
return metrics;
|
|
813
|
+
}
|
|
814
|
+
function readJson(path, label, diagnostics) {
|
|
815
|
+
try {
|
|
816
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
817
|
+
return asRecord(parsed);
|
|
818
|
+
}
|
|
819
|
+
catch (error) {
|
|
820
|
+
diagnostics.push({ kind: 'dbt', filePath: path, severity: 'error', message: `could not read ${label}: ${error instanceof Error ? error.message : String(error)}` });
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function readYaml(filePath, diagnostics) {
|
|
825
|
+
try {
|
|
826
|
+
return asRecord(yaml.load(readFileSync(filePath, 'utf8')));
|
|
827
|
+
}
|
|
828
|
+
catch (error) {
|
|
829
|
+
diagnostics.push(modelingError(filePath, `could not parse YAML: ${error instanceof Error ? error.message : String(error)}`));
|
|
830
|
+
return undefined;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
function scanYaml(dir) {
|
|
834
|
+
if (!existsSync(dir))
|
|
835
|
+
return [];
|
|
836
|
+
const files = [];
|
|
837
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
838
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
839
|
+
continue;
|
|
840
|
+
const fullPath = join(dir, entry.name);
|
|
841
|
+
if (entry.isDirectory())
|
|
842
|
+
files.push(...scanYaml(fullPath));
|
|
843
|
+
else if (entry.isFile() && (extname(entry.name) === '.yaml' || extname(entry.name) === '.yml'))
|
|
844
|
+
files.push(fullPath);
|
|
845
|
+
}
|
|
846
|
+
return files.sort();
|
|
847
|
+
}
|
|
848
|
+
function catalogNode(rawCatalog, uniqueId) {
|
|
849
|
+
return asRecord(asRecord(rawCatalog?.nodes)[uniqueId] ?? asRecord(rawCatalog?.sources)[uniqueId]);
|
|
850
|
+
}
|
|
851
|
+
function hasCatalogTypes(entry) {
|
|
852
|
+
return Object.values(asRecord(entry.columns)).some((column) => Boolean(stringValue(asRecord(column).type)));
|
|
853
|
+
}
|
|
854
|
+
function hasDbtTests(node) {
|
|
855
|
+
return Object.values(asRecord(node.columns)).some((column) => {
|
|
856
|
+
const rawColumn = asRecord(column);
|
|
857
|
+
const tests = rawColumn.tests ?? rawColumn.data_tests;
|
|
858
|
+
return Array.isArray(tests) ? tests.length > 0 : Object.keys(asRecord(tests)).length > 0;
|
|
859
|
+
}) || Object.keys(asRecord(node.tests)).length > 0;
|
|
860
|
+
}
|
|
861
|
+
function relationName(node, name) {
|
|
862
|
+
const database = stringValue(node.database);
|
|
863
|
+
const schema = stringValue(node.schema);
|
|
864
|
+
return [database, schema, name].filter(Boolean).join('.') || undefined;
|
|
865
|
+
}
|
|
866
|
+
function relationshipKeys(value) {
|
|
867
|
+
if (!Array.isArray(value))
|
|
868
|
+
return [];
|
|
869
|
+
return value.flatMap((item) => {
|
|
870
|
+
const pair = asRecord(item);
|
|
871
|
+
const from = stringValue(pair.from);
|
|
872
|
+
const to = stringValue(pair.to);
|
|
873
|
+
return from && to ? [{ from, to }] : [];
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
function relationshipCardinality(value) {
|
|
877
|
+
return value === 'one_to_one' || value === 'one_to_many' || value === 'many_to_one' || value === 'many_to_many'
|
|
878
|
+
? value
|
|
879
|
+
: 'unknown';
|
|
880
|
+
}
|
|
881
|
+
function fanoutPolicy(value) {
|
|
882
|
+
return value === 'safe' || value === 'attribution_required' || value === 'unsafe' ? value : 'unknown';
|
|
883
|
+
}
|
|
884
|
+
function lifecycle(value) {
|
|
885
|
+
return value === 'review' || value === 'certified' || value === 'deprecated' ? value : 'draft';
|
|
886
|
+
}
|
|
887
|
+
function ruleKind(value) {
|
|
888
|
+
return value === 'fanout' || value === 'export' || value === 'contract' ? value : 'custom';
|
|
889
|
+
}
|
|
890
|
+
function analyticalRole(value) {
|
|
891
|
+
return value === 'event' || value === 'dimension' || value === 'snapshot' || value === 'bridge' ? value : 'unknown';
|
|
892
|
+
}
|
|
893
|
+
function positiveInteger(value, fallback) {
|
|
894
|
+
const parsed = Number(value);
|
|
895
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
896
|
+
}
|
|
897
|
+
function arrayOfRecords(value) {
|
|
898
|
+
return Array.isArray(value) ? value.map(asRecord).filter((record) => Object.keys(record).length > 0) : [];
|
|
899
|
+
}
|
|
900
|
+
function stringArray(value) {
|
|
901
|
+
if (typeof value === 'string')
|
|
902
|
+
return [value];
|
|
903
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
904
|
+
}
|
|
905
|
+
function stringValue(value) {
|
|
906
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
907
|
+
}
|
|
908
|
+
function asRecord(value) {
|
|
909
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
910
|
+
}
|
|
911
|
+
function sortRecord(value) {
|
|
912
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
913
|
+
}
|
|
914
|
+
function fingerprint(value) {
|
|
915
|
+
return createHash('sha256').update(JSON.stringify(stable(value))).digest('hex');
|
|
916
|
+
}
|
|
917
|
+
function fileFingerprint(path) {
|
|
918
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
919
|
+
}
|
|
920
|
+
function stable(value) {
|
|
921
|
+
if (Array.isArray(value))
|
|
922
|
+
return value.map(stable);
|
|
923
|
+
const record = asRecord(value);
|
|
924
|
+
if (Object.keys(record).length === 0)
|
|
925
|
+
return value;
|
|
926
|
+
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, stable(record[key])]));
|
|
927
|
+
}
|
|
928
|
+
function modelingError(filePath, message) {
|
|
929
|
+
return { kind: 'modeling', filePath, severity: 'error', message };
|
|
930
|
+
}
|
|
931
|
+
//# sourceMappingURL=dbt-first-modeling.js.map
|