@google-cloud/kcmd 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +203 -0
- package/README.md +266 -2
- package/build/ts/kcmd/cli.js +19578 -0
- package/build/ts/kcmd/gcp/alloydb.d.ts +12 -0
- package/build/ts/kcmd/gcp/alloydb.js +70 -0
- package/build/ts/kcmd/gcp/api.d.ts +29 -0
- package/build/ts/kcmd/gcp/api.js +162 -0
- package/build/ts/kcmd/gcp/biglake.d.ts +16 -0
- package/build/ts/kcmd/gcp/biglake.js +50 -0
- package/build/ts/kcmd/gcp/bigquery.d.ts +57 -0
- package/build/ts/kcmd/gcp/bigquery.js +134 -0
- package/build/ts/kcmd/gcp/context.d.ts +10 -0
- package/build/ts/kcmd/gcp/context.js +74 -0
- package/build/ts/kcmd/gcp/crm.d.ts +13 -0
- package/build/ts/kcmd/gcp/crm.js +91 -0
- package/build/ts/kcmd/gcp/dataplex.d.ts +147 -0
- package/build/ts/kcmd/gcp/dataplex.js +406 -0
- package/build/ts/kcmd/gcp/index.d.ts +3 -0
- package/build/ts/kcmd/gcp/index.js +21 -0
- package/build/ts/kcmd/gcp/spanner.d.ts +63 -0
- package/build/ts/kcmd/gcp/spanner.js +123 -0
- package/build/ts/kcmd/index.d.ts +7 -0
- package/build/ts/kcmd/index.js +46 -0
- package/build/ts/kcmd/layout.d.ts +16 -0
- package/build/ts/kcmd/layout.js +31 -0
- package/build/ts/kcmd/layouts/documents.d.ts +24 -0
- package/build/ts/kcmd/layouts/documents.js +264 -0
- package/build/ts/kcmd/layouts/okf.d.ts +31 -0
- package/build/ts/kcmd/layouts/okf.js +344 -0
- package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
- package/build/ts/kcmd/layouts/semantic-model.js +542 -0
- package/build/ts/kcmd/layouts/standard.d.ts +13 -0
- package/build/ts/kcmd/layouts/standard.js +108 -0
- package/build/ts/kcmd/manifest.d.ts +34 -0
- package/build/ts/kcmd/manifest.js +262 -0
- package/build/ts/kcmd/metadata.d.ts +25 -0
- package/build/ts/kcmd/metadata.js +8 -0
- package/build/ts/kcmd/okf/catalog_residue.d.ts +14 -0
- package/build/ts/kcmd/okf/catalog_residue.js +124 -0
- package/build/ts/kcmd/okf/concept.d.ts +14 -0
- package/build/ts/kcmd/okf/concept.js +104 -0
- package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
- package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
- package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
- package/build/ts/kcmd/okf/init_okf_workspace.js +115 -0
- package/build/ts/kcmd/okf/links.d.ts +23 -0
- package/build/ts/kcmd/okf/links.js +468 -0
- package/build/ts/kcmd/okf/okf_metadata.d.ts +12 -0
- package/build/ts/kcmd/okf/okf_metadata.js +82 -0
- package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
- package/build/ts/kcmd/semantic/bigquery.js +525 -0
- package/build/ts/kcmd/semantic/binding.d.ts +3 -0
- package/build/ts/kcmd/semantic/binding.js +97 -0
- package/build/ts/kcmd/semantic/catalog_entry.d.ts +24 -0
- package/build/ts/kcmd/semantic/catalog_entry.js +35 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.d.ts +26 -0
- package/build/ts/kcmd/semantic/converters/owl/convert.js +41 -0
- package/build/ts/kcmd/semantic/converters/owl/model.d.ts +98 -0
- package/build/ts/kcmd/semantic/converters/owl/model.js +30 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.d.ts +11 -0
- package/build/ts/kcmd/semantic/converters/owl/parse.js +634 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.d.ts +19 -0
- package/build/ts/kcmd/semantic/converters/owl/to_ir.js +390 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.d.ts +20 -0
- package/build/ts/kcmd/semantic/deploy_bigquery.js +226 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.d.ts +129 -0
- package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -0
- package/build/ts/kcmd/semantic/deploy_spanner.d.ts +15 -0
- package/build/ts/kcmd/semantic/deploy_spanner.js +150 -0
- package/build/ts/kcmd/semantic/deployment_target.d.ts +93 -0
- package/build/ts/kcmd/semantic/deployment_target.js +379 -0
- package/build/ts/kcmd/semantic/graph_ddl.d.ts +34 -0
- package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
- package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
- package/build/ts/kcmd/semantic/import_model.js +1275 -0
- package/build/ts/kcmd/semantic/ir.d.ts +589 -0
- package/build/ts/kcmd/semantic/ir.js +247 -0
- package/build/ts/kcmd/semantic/kc_actions.d.ts +45 -0
- package/build/ts/kcmd/semantic/kc_actions.js +531 -0
- package/build/ts/kcmd/semantic/kc_constraints.d.ts +44 -0
- package/build/ts/kcmd/semantic/kc_constraints.js +279 -0
- package/build/ts/kcmd/semantic/kc_converter.d.ts +31 -0
- package/build/ts/kcmd/semantic/kc_converter.js +824 -0
- package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
- package/build/ts/kcmd/semantic/kc_custom_types.js +828 -0
- package/build/ts/kcmd/semantic/kc_entries.d.ts +155 -0
- package/build/ts/kcmd/semantic/kc_entries.js +284 -0
- package/build/ts/kcmd/semantic/kc_ids.d.ts +103 -0
- package/build/ts/kcmd/semantic/kc_ids.js +191 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
- package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
- package/build/ts/kcmd/semantic/loader.d.ts +262 -0
- package/build/ts/kcmd/semantic/loader.js +3440 -0
- package/build/ts/kcmd/semantic/names.d.ts +10 -0
- package/build/ts/kcmd/semantic/names.js +85 -0
- package/build/ts/kcmd/semantic/osi_converter.d.ts +60 -0
- package/build/ts/kcmd/semantic/osi_converter.js +911 -0
- package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
- package/build/ts/kcmd/semantic/parameters.js +152 -0
- package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
- package/build/ts/kcmd/semantic/pull_kc.js +346 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
- package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
- package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
- package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.d.ts +93 -0
- package/build/ts/kcmd/semantic/runtime/agent_tools.js +243 -0
- package/build/ts/kcmd/semantic/runtime/dialect.d.ts +13 -0
- package/build/ts/kcmd/semantic/runtime/dialect.js +39 -0
- package/build/ts/kcmd/semantic/runtime/runtime.d.ts +94 -0
- package/build/ts/kcmd/semantic/runtime/runtime.js +318 -0
- package/build/ts/kcmd/semantic/runtime/store.d.ts +112 -0
- package/build/ts/kcmd/semantic/runtime/store.js +344 -0
- package/build/ts/kcmd/semantic/skills.d.ts +110 -0
- package/build/ts/kcmd/semantic/skills.js +697 -0
- package/build/ts/kcmd/semantic/source_dialect.d.ts +10 -0
- package/build/ts/kcmd/semantic/source_dialect.js +261 -0
- package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
- package/build/ts/kcmd/semantic/source_entries.js +282 -0
- package/build/ts/kcmd/semantic/spanner.d.ts +18 -0
- package/build/ts/kcmd/semantic/spanner.js +60 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.d.ts +17 -0
- package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -0
- package/build/ts/kcmd/semantic/sql_identifiers.d.ts +6 -0
- package/build/ts/kcmd/semantic/sql_identifiers.js +267 -0
- package/build/ts/kcmd/semantic/sql_parser.d.ts +44 -0
- package/build/ts/kcmd/semantic/sql_parser.js +378 -0
- package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
- package/build/ts/kcmd/semantic/transpile.js +563 -0
- package/build/ts/kcmd/semantic/validate.d.ts +35 -0
- package/build/ts/kcmd/semantic/validate.js +1899 -0
- package/build/ts/kcmd/semantic/yaml_options.d.ts +6 -0
- package/build/ts/kcmd/semantic/yaml_options.js +58 -0
- package/build/ts/kcmd/semantic/yaml_update.d.ts +26 -0
- package/build/ts/kcmd/semantic/yaml_update.js +782 -0
- package/build/ts/kcmd/snapshot.d.ts +33 -0
- package/build/ts/kcmd/snapshot.js +379 -0
- package/build/ts/kcmd/source.d.ts +31 -0
- package/build/ts/kcmd/source.js +119 -0
- package/build/ts/kcmd/sources/bq-dataset.d.ts +17 -0
- package/build/ts/kcmd/sources/bq-dataset.js +131 -0
- package/build/ts/kcmd/sources/entrygroup.d.ts +21 -0
- package/build/ts/kcmd/sources/entrygroup.js +83 -0
- package/build/ts/kcmd/sources/kb.d.ts +21 -0
- package/build/ts/kcmd/sources/kb.js +82 -0
- package/build/ts/kcmd/sources/semantic-model.d.ts +18 -0
- package/build/ts/kcmd/sources/semantic-model.js +48 -0
- package/build/ts/kcmd/sync.d.ts +35 -0
- package/build/ts/kcmd/sync.js +697 -0
- package/package.json +62 -4
|
@@ -0,0 +1,1396 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Deploys a semantic model's Knowledge Catalog resources.
|
|
3
|
+
//
|
|
4
|
+
// This is the Knowledge Catalog leg of `kcmd push` for the semantic-model
|
|
5
|
+
// scope, the counterpart to `deploy_bigquery.ts`. It consumes models already
|
|
6
|
+
// parsed into the semantic IR (see loadSemanticModels, shared with the BigQuery
|
|
7
|
+
// leg so a multi-destination push parses each document once), maps each to
|
|
8
|
+
// catalog Entries + Aspects (the pure emitter in knowledge_catalog.ts), and
|
|
9
|
+
// writes them through the Knowledge Catalog client.
|
|
10
|
+
//
|
|
11
|
+
// Types: the `semantic-model`/`semantic-entity`/`semantic-metric` entry and
|
|
12
|
+
// aspect types — and the built-in `schema` aspect — are built-in system types
|
|
13
|
+
// in `dataplex-types/global`, while `kcmd`-owned custom types (such as
|
|
14
|
+
// `semantic-action` and `semantic-constraint`) live in the destination project.
|
|
15
|
+
// Push does NOT provision any type, nor the entry group (those are created at
|
|
16
|
+
// `init`); it only validates and writes entries and entry links. The caller
|
|
17
|
+
// needs `dataplex.entryGroups.useSemanticModelAspect` on the destination entry
|
|
18
|
+
// group.
|
|
19
|
+
//
|
|
20
|
+
// Publish sequence (split into read-only `preflightKnowledgeCatalog` and write
|
|
21
|
+
// `applyKnowledgeCatalog`):
|
|
22
|
+
// * Preflight validates that exactly one model is deployed per entry group
|
|
23
|
+
// and that each file copy fits in one aspect, snapshots the destination
|
|
24
|
+
// entry group to guard against foreign models (unless `--force-remove` is
|
|
25
|
+
// set), and checks that any required custom entry/aspect types exist in
|
|
26
|
+
// the target project. With the second-generation aspects, it also checks
|
|
27
|
+
// every aspect's size.
|
|
28
|
+
// * Apply creates or updates each model's entries in dependency order: the
|
|
29
|
+
// `semantic-model` anchor first, then independent child entries
|
|
30
|
+
// concurrently, and finally dependent child entries (`semantic-metric` and
|
|
31
|
+
// `semantic-explore`).
|
|
32
|
+
// * Relationship edges are published as entry links between two entity
|
|
33
|
+
// entries after that model's entries exist: a `schema-join` link per
|
|
34
|
+
// relationship, or with v2Aspects a `semantic-link` per ordered entity
|
|
35
|
+
// pair, a `reference` link from each relationship entry to each of its
|
|
36
|
+
// entities, and one from each entity to each parent it extends. A re-push
|
|
37
|
+
// first deletes any orphaned links (no longer emitted) and stale links
|
|
38
|
+
// (whose type or endpoint references changed), then creates new or
|
|
39
|
+
// recreated links and updates unchanged links' aspects in place. The
|
|
40
|
+
// caller additionally needs
|
|
41
|
+
// `dataplex.entryGroups.useSchemaJoinEntryLink` and `useSchemaJoinAspect`,
|
|
42
|
+
// and with v2Aspects `useReferenceEntryLink`, on the destination entry
|
|
43
|
+
// group.
|
|
44
|
+
// * Reconcile deletions: after writing, delete any child entry this push owns
|
|
45
|
+
// (by `ownedPrefixes`) that is present in the pre-write listing but was not
|
|
46
|
+
// re-emitted.
|
|
47
|
+
//
|
|
48
|
+
// This is a library module: it emits no console output. Warnings and the
|
|
49
|
+
// dry-run plan are returned in `KcDeployResult` for the CLI (commands.ts) to
|
|
50
|
+
// print.
|
|
51
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
52
|
+
if (k2 === undefined) k2 = k;
|
|
53
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
54
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
55
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
56
|
+
}
|
|
57
|
+
Object.defineProperty(o, k2, desc);
|
|
58
|
+
}) : (function(o, m, k, k2) {
|
|
59
|
+
if (k2 === undefined) k2 = k;
|
|
60
|
+
o[k2] = m[k];
|
|
61
|
+
}));
|
|
62
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
63
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
64
|
+
}) : function(o, v) {
|
|
65
|
+
o["default"] = v;
|
|
66
|
+
});
|
|
67
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
68
|
+
var ownKeys = function(o) {
|
|
69
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
70
|
+
var ar = [];
|
|
71
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
72
|
+
return ar;
|
|
73
|
+
};
|
|
74
|
+
return ownKeys(o);
|
|
75
|
+
};
|
|
76
|
+
return function (mod) {
|
|
77
|
+
if (mod && mod.__esModule) return mod;
|
|
78
|
+
var result = {};
|
|
79
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
80
|
+
__setModuleDefault(result, mod);
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
})();
|
|
84
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
85
|
+
exports.MAX_ASPECT_DATA_BYTES = void 0;
|
|
86
|
+
exports.preflightKnowledgeCatalog = preflightKnowledgeCatalog;
|
|
87
|
+
exports.applyKnowledgeCatalog = applyKnowledgeCatalog;
|
|
88
|
+
exports.deployKnowledgeCatalog = deployKnowledgeCatalog;
|
|
89
|
+
exports.deployEmittedModels = deployEmittedModels;
|
|
90
|
+
exports.oneModelPerEntryGroupMessage = oneModelPerEntryGroupMessage;
|
|
91
|
+
exports.checkFileCopies = checkFileCopies;
|
|
92
|
+
exports.checkCatalogAspects = checkCatalogAspects;
|
|
93
|
+
exports.checkCustomTypes = checkCustomTypes;
|
|
94
|
+
const crm = __importStar(require("../gcp/crm"));
|
|
95
|
+
const dataplex_1 = require("../gcp/dataplex");
|
|
96
|
+
const kc_custom_types_1 = require("./kc_custom_types");
|
|
97
|
+
const kc_entries_1 = require("./kc_entries");
|
|
98
|
+
const kc_ids_1 = require("./kc_ids");
|
|
99
|
+
const kcEmit = __importStar(require("./knowledge_catalog"));
|
|
100
|
+
// entries.create propagation retry: a just-created entry group can briefly 404.
|
|
101
|
+
const ENTRY_CREATE_TRIES = 3;
|
|
102
|
+
const ENTRY_CREATE_RETRY_MS = 3000;
|
|
103
|
+
function sleep(ms) {
|
|
104
|
+
return new Promise(r => setTimeout(r, ms));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Runs every read-only check for a Knowledge Catalog push without writing to
|
|
108
|
+
* the catalog.
|
|
109
|
+
*
|
|
110
|
+
* The method performs the following actions:
|
|
111
|
+
* 1. Translates the IR models into catalog resources.
|
|
112
|
+
* 2. Verifies that exactly one model is being deployed and that each file
|
|
113
|
+
* copy fits in one aspect, builds the dry-run plan, and returns early
|
|
114
|
+
* when `opts.validateOnly` is set.
|
|
115
|
+
* 3. Lists the destination entry group and fails if an unrecognized model's
|
|
116
|
+
* anchor entry is present without `--force-remove`.
|
|
117
|
+
* 4. Verifies that every custom entry type and aspect type required by the
|
|
118
|
+
* model exists in the target project. Skips the file-copy type when
|
|
119
|
+
* `opts.fileCopyTypeChecked` is set.
|
|
120
|
+
* 5. Returns the emitted resources, entry-group listing, warnings, and plan
|
|
121
|
+
* as `prepared` for `applyKnowledgeCatalog`.
|
|
122
|
+
* With `opts.v2Aspects` set, step 2 also checks every aspect's size.
|
|
123
|
+
*/
|
|
124
|
+
async function preflightKnowledgeCatalog(models, ctx, opts) {
|
|
125
|
+
const emit = emitModels(models, opts);
|
|
126
|
+
if (emit.error) {
|
|
127
|
+
return {
|
|
128
|
+
result: {
|
|
129
|
+
success: false,
|
|
130
|
+
details: emit.error,
|
|
131
|
+
warnings: emit.warnings,
|
|
132
|
+
created: 0,
|
|
133
|
+
updated: 0,
|
|
134
|
+
deleted: 0,
|
|
135
|
+
linked: 0,
|
|
136
|
+
unlinked: 0,
|
|
137
|
+
plan: [],
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return preflightEmittedModels(emit.emitted, emit.warnings, ctx, opts);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Writes a preflight-checked Knowledge Catalog deployment.
|
|
145
|
+
*
|
|
146
|
+
* The method performs the following actions:
|
|
147
|
+
* 1. Removes foreign models when `--force-remove` is set.
|
|
148
|
+
* 2. Writes each emitted model's entries and relationship links and deletes
|
|
149
|
+
* any orphaned links and entries.
|
|
150
|
+
*/
|
|
151
|
+
async function applyKnowledgeCatalog(prepared, ctx, opts) {
|
|
152
|
+
const warnings = [...prepared.warnings];
|
|
153
|
+
const counts = { created: 0, updated: 0, deleted: 0, linked: 0, unlinked: 0 };
|
|
154
|
+
const plan = prepared.plan;
|
|
155
|
+
const result = (success, details) => ({ success, warnings, ...counts, plan, ...(details ? { details } : {}) });
|
|
156
|
+
const cat = new dataplex_1.CatalogClient(ctx);
|
|
157
|
+
const { emitted, existing } = prepared;
|
|
158
|
+
// Resource Manager is asked for the project number at most once per push.
|
|
159
|
+
const isScopeProject = scopeProjectMatcher(ctx, opts.project);
|
|
160
|
+
// Some of this model's writes may have landed before a failure in them.
|
|
161
|
+
// The catalog may then lack some or all of this push's file copies, and
|
|
162
|
+
// pull refuses that until a push succeeds.
|
|
163
|
+
const storesCopies = emitted.some(m => m.resources.entries.some(e => (0, kc_entries_1.entryTypeId)(e) === kc_custom_types_1.FILE_COPY_TYPE_ID));
|
|
164
|
+
const failed = (details) => result(false, storesCopies ?
|
|
165
|
+
`${details.replace(/\.$/, '')}. The catalog may now lack some or ` +
|
|
166
|
+
`all of this push's file copies, so push again before you pull.` :
|
|
167
|
+
details);
|
|
168
|
+
if (opts.forceRemove) {
|
|
169
|
+
const foreignAnchorIds = findForeignAnchorIds(emitted[0], existing);
|
|
170
|
+
if (foreignAnchorIds.length) {
|
|
171
|
+
// This runs before any of this model's writes. A failure here leaves
|
|
172
|
+
// none of this push's file copies, so it gets the note too, and so
|
|
173
|
+
// does a thrown error.
|
|
174
|
+
const removed = await removeForeignModels(cat, opts, existing, foreignAnchorIds, isScopeProject)
|
|
175
|
+
.catch((err) => ({
|
|
176
|
+
deleted: 0,
|
|
177
|
+
unlinked: 0,
|
|
178
|
+
error: String(err?.message || err).trim(),
|
|
179
|
+
}));
|
|
180
|
+
counts.deleted += removed.deleted;
|
|
181
|
+
counts.unlinked += removed.unlinked;
|
|
182
|
+
if (removed.error)
|
|
183
|
+
return failed(removed.error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Write each emitted model's entries and relationship links and delete any
|
|
187
|
+
// orphaned resources. A thrown error, such as a network failure, can stop
|
|
188
|
+
// the writes partway too, so it is reported the same way. The counts then
|
|
189
|
+
// leave out what the step that threw had already written.
|
|
190
|
+
const written = await writeEmittedModels(cat, opts, emitted, existing, counts, isScopeProject)
|
|
191
|
+
.catch((err) => ({ error: String(err?.message || err).trim() }));
|
|
192
|
+
if (written.error)
|
|
193
|
+
return failed(written.error);
|
|
194
|
+
return result(true);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Deploys every authored model's Knowledge Catalog resources.
|
|
198
|
+
*
|
|
199
|
+
* Runs `preflightKnowledgeCatalog` followed by `applyKnowledgeCatalog`, and
|
|
200
|
+
* passes the warnings to `opts.onWarnings` between the two.
|
|
201
|
+
*/
|
|
202
|
+
async function deployKnowledgeCatalog(models, ctx, opts) {
|
|
203
|
+
const pre = await preflightKnowledgeCatalog(models, ctx, opts);
|
|
204
|
+
if (pre.result)
|
|
205
|
+
return pre.result;
|
|
206
|
+
opts.onWarnings?.(pre.prepared.warnings);
|
|
207
|
+
return applyKnowledgeCatalog(pre.prepared, ctx, opts);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Publishes models that are already mapped to catalog resources.
|
|
211
|
+
*
|
|
212
|
+
* Origin-agnostic wrapper over `preflightEmittedModels` and
|
|
213
|
+
* `applyKnowledgeCatalog` for callers with their own emitter (such as LookML).
|
|
214
|
+
*/
|
|
215
|
+
async function deployEmittedModels(emitted, emitWarnings, ctx, opts) {
|
|
216
|
+
const pre = await preflightEmittedModels(emitted, emitWarnings, ctx, opts);
|
|
217
|
+
if (pre.result)
|
|
218
|
+
return pre.result;
|
|
219
|
+
opts.onWarnings?.(pre.prepared.warnings);
|
|
220
|
+
return applyKnowledgeCatalog(pre.prepared, ctx, opts);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Runs read-only preflight checks on already-emitted models.
|
|
224
|
+
*
|
|
225
|
+
* The method performs the following checks:
|
|
226
|
+
* 1. Validates locally that `emitted` contains a single model, that each
|
|
227
|
+
* file copy fits in one aspect and, with `opts.v2Aspects` set, that every
|
|
228
|
+
* aspect size and instance count is within its limit. Builds the
|
|
229
|
+
* dry-run plan and returns early when `opts.validateOnly` is set.
|
|
230
|
+
* 2. Lists the destination entry group and validates that it contains no
|
|
231
|
+
* foreign models unless `--force-remove` is set.
|
|
232
|
+
* 3. Validates that every custom type used by the model exists in the
|
|
233
|
+
* target project. Skips the file-copy type when
|
|
234
|
+
* `opts.fileCopyTypeChecked` is set.
|
|
235
|
+
*/
|
|
236
|
+
async function preflightEmittedModels(emitted, emitWarnings, ctx, opts) {
|
|
237
|
+
const warnings = [...emitWarnings];
|
|
238
|
+
const counts = { created: 0, updated: 0, deleted: 0, linked: 0, unlinked: 0 };
|
|
239
|
+
let plan = [];
|
|
240
|
+
const result = (success, details) => ({ success, warnings, ...counts, plan, ...(details ? { details } : {}) });
|
|
241
|
+
// 1. Local validation (offline, zero network calls).
|
|
242
|
+
const modelsValidation = validateEmittedModels(emitted, opts, warnings, result);
|
|
243
|
+
if (!modelsValidation.success || !emitted.length) {
|
|
244
|
+
return { result: modelsValidation };
|
|
245
|
+
}
|
|
246
|
+
// Once `validateEmittedModels` passes and `emitted` is non-empty, `emitted`
|
|
247
|
+
// is guaranteed to contain exactly one model (`emitted[0]`).
|
|
248
|
+
const model = emitted[0];
|
|
249
|
+
const sizeValidation = validateFileCopySizes(model, result);
|
|
250
|
+
if (!sizeValidation.success)
|
|
251
|
+
return { result: sizeValidation };
|
|
252
|
+
const aspectsError = opts.v2Aspects ? emittedAspectsError(model) : undefined;
|
|
253
|
+
if (aspectsError)
|
|
254
|
+
return { result: result(false, aspectsError) };
|
|
255
|
+
plan = buildPlan(emitted, opts);
|
|
256
|
+
if (opts.validateOnly)
|
|
257
|
+
return { result: result(true) };
|
|
258
|
+
// 2. Snapshot the entry group once, before any write: the same listing feeds
|
|
259
|
+
// the foreign-model guard, link reconciliation, and deletion
|
|
260
|
+
// reconciliation. A re-emitted entry is never a deletion candidate, so a
|
|
261
|
+
// pre-write snapshot is correct for all three.
|
|
262
|
+
const cat = new dataplex_1.CatalogClient(ctx);
|
|
263
|
+
const listing = await listEntryGroup(cat, opts);
|
|
264
|
+
if (listing.error)
|
|
265
|
+
return { result: result(false, listing.error) };
|
|
266
|
+
const existing = listing.entries;
|
|
267
|
+
const groupValidation = validateEntryGroup(model, existing, opts, result);
|
|
268
|
+
if (!groupValidation.success)
|
|
269
|
+
return { result: groupValidation };
|
|
270
|
+
// 3. Validate that every kcmd-owned custom type used by the model exists in
|
|
271
|
+
// the target project.
|
|
272
|
+
const customTypesValidation = await validateCustomTypes(cat, model, opts, result);
|
|
273
|
+
if (!customTypesValidation.success)
|
|
274
|
+
return { result: customTypesValidation };
|
|
275
|
+
return { prepared: { emitted, existing, warnings, plan } };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Returns any `semantic-model` anchor IDs in `existing` that do not match the
|
|
279
|
+
* input `model`'s anchor ID.
|
|
280
|
+
*
|
|
281
|
+
* A foreign anchor ID exists when the remote entry group already contains a
|
|
282
|
+
* different `semantic-model` entry (for example, when the model was renamed
|
|
283
|
+
* locally or a different model is pushed to the same entry group).
|
|
284
|
+
*/
|
|
285
|
+
function findForeignAnchorIds(model, existing) {
|
|
286
|
+
const modelAnchorId = (0, kc_entries_1.anchorId)(model?.resources.entries[0]);
|
|
287
|
+
return existing.map(kc_entries_1.anchorId).filter((id) => id !== undefined && id !== modelAnchorId);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Validates that `emitted` contains a single model.
|
|
291
|
+
*
|
|
292
|
+
* Only one semantic model per entry group is supported (so two models cannot
|
|
293
|
+
* share an entry group or collide on entry IDs, and `kcmd pull` can
|
|
294
|
+
* unambiguously read the group back).
|
|
295
|
+
*
|
|
296
|
+
* An empty workspace is a clean no-op under `--validate-only` and a
|
|
297
|
+
* configuration error on a real push.
|
|
298
|
+
*/
|
|
299
|
+
function validateEmittedModels(emitted, opts, warnings, result) {
|
|
300
|
+
if (!emitted.length) {
|
|
301
|
+
if (opts.validateOnly) {
|
|
302
|
+
warnings.push('No semantic model documents found; nothing to validate.');
|
|
303
|
+
return result(true);
|
|
304
|
+
}
|
|
305
|
+
return result(false, 'No semantic model documents found; nothing to deploy.');
|
|
306
|
+
}
|
|
307
|
+
if (emitted.length > 1) {
|
|
308
|
+
return result(false, oneModelPerEntryGroupMessage(opts.entryGroup, emitted.length));
|
|
309
|
+
}
|
|
310
|
+
return result(true);
|
|
311
|
+
}
|
|
312
|
+
/** The error for an entry group that would receive more than one model. */
|
|
313
|
+
function oneModelPerEntryGroupMessage(entryGroup, models) {
|
|
314
|
+
return `entry group '${entryGroup}' would receive ${models} models, but ` +
|
|
315
|
+
`only one model per entry group is supported; split them into ` +
|
|
316
|
+
`separate entry groups.`;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Validates that `existing` (the destination entry-group snapshot) contains no
|
|
320
|
+
* foreign `semantic-model` anchors unless `--force-remove` is set.
|
|
321
|
+
*
|
|
322
|
+
* If the entry group already contains a different model (for example, when a
|
|
323
|
+
* model was renamed or replaced locally), pushing without removing the old
|
|
324
|
+
* model would leave multiple models in the same entry group. We fail by
|
|
325
|
+
* default unless the caller passes `--force-remove` to delete the old model.
|
|
326
|
+
*/
|
|
327
|
+
function validateEntryGroup(model, existing, opts, result) {
|
|
328
|
+
const foreignAnchorIds = findForeignAnchorIds(model, existing);
|
|
329
|
+
if (foreignAnchorIds.length && !opts.forceRemove) {
|
|
330
|
+
return result(false, `entry group '${opts.entryGroup}' already contains model(s) this ` +
|
|
331
|
+
`push does not include: ${foreignAnchorIds.join(', ')}. Re-run ` +
|
|
332
|
+
`with --force-remove to delete them, or add their documents to ` +
|
|
333
|
+
`this push.`);
|
|
334
|
+
}
|
|
335
|
+
return result(true);
|
|
336
|
+
}
|
|
337
|
+
// The largest aspect data Dataplex accepts: 120 KiB of its JSON form, in UTF-8
|
|
338
|
+
// bytes, with escaping counted. The schema aspect may hold 2 MiB.
|
|
339
|
+
exports.MAX_ASPECT_DATA_BYTES = 120 * 1024;
|
|
340
|
+
const MAX_SCHEMA_ASPECT_DATA_BYTES = 2 * 1024 * 1024;
|
|
341
|
+
/**
|
|
342
|
+
* Validates that every file copy in `model` fits in one aspect.
|
|
343
|
+
*
|
|
344
|
+
* A file too large to store then fails before any write, naming the file,
|
|
345
|
+
* rather than partway through the writes. Push runs the same check on its
|
|
346
|
+
* copies, with `checkFileCopies`, before any graph deploys.
|
|
347
|
+
*/
|
|
348
|
+
function validateFileCopySizes(model, result) {
|
|
349
|
+
for (const entry of model.resources.entries) {
|
|
350
|
+
if ((0, kc_entries_1.entryTypeId)(entry) !== kc_custom_types_1.FILE_COPY_TYPE_ID)
|
|
351
|
+
continue;
|
|
352
|
+
for (const aspect of Object.values(entry.aspects ?? {})) {
|
|
353
|
+
const error = fileCopySizeError(aspect.data ?? {});
|
|
354
|
+
if (error)
|
|
355
|
+
return result(false, error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return result(true);
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Checks a push's file copies before anything is written. Returns the error
|
|
362
|
+
* to report, or undefined when every copy fits in one aspect and no two file
|
|
363
|
+
* names map to one entry id.
|
|
364
|
+
*
|
|
365
|
+
* Push calls it before any graph deploys. Every copy of a model shares the
|
|
366
|
+
* model's part of the entry id, so two file names collide when their slugs
|
|
367
|
+
* do.
|
|
368
|
+
*/
|
|
369
|
+
function checkFileCopies(files) {
|
|
370
|
+
const bySlug = new Map();
|
|
371
|
+
for (const file of files) {
|
|
372
|
+
const tooLarge = fileCopySizeError(kcEmit.fileCopyData(file));
|
|
373
|
+
if (tooLarge)
|
|
374
|
+
return tooLarge;
|
|
375
|
+
const earlier = bySlug.get((0, kc_ids_1.slug)(file.fileName));
|
|
376
|
+
if (earlier !== undefined) {
|
|
377
|
+
return `files '${earlier}' and '${file.fileName}' both map to one ` +
|
|
378
|
+
`entry id; rename one of them`;
|
|
379
|
+
}
|
|
380
|
+
bySlug.set((0, kc_ids_1.slug)(file.fileName), file.fileName);
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
// Why the aspect data of one file copy is too large to store, or undefined
|
|
385
|
+
// when it fits.
|
|
386
|
+
function fileCopySizeError(data) {
|
|
387
|
+
const bytes = new TextEncoder().encode(JSON.stringify(data)).length;
|
|
388
|
+
if (bytes <= exports.MAX_ASPECT_DATA_BYTES)
|
|
389
|
+
return undefined;
|
|
390
|
+
return `file '${data.fileName}' is too large to store in Knowledge ` +
|
|
391
|
+
`Catalog: its copy takes ${bytes} bytes, and one aspect holds at most ` +
|
|
392
|
+
`${exports.MAX_ASPECT_DATA_BYTES}.`;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The emitter's warnings for `models` with v2Aspects on, and why push's
|
|
396
|
+
* catalog leg cannot write them: an emit error or an aspect too large to
|
|
397
|
+
* store. Calls no service.
|
|
398
|
+
*/
|
|
399
|
+
function checkCatalogAspects(models, opts) {
|
|
400
|
+
const emit = emitModels(models, opts);
|
|
401
|
+
const error = emit.error ?? emit.emitted.map(emittedAspectsError).find(e => e);
|
|
402
|
+
return { error, warnings: emit.warnings };
|
|
403
|
+
}
|
|
404
|
+
// Why push cannot write what `model` emits, or undefined when it can.
|
|
405
|
+
function emittedAspectsError(model) {
|
|
406
|
+
for (const entry of model.resources.entries) {
|
|
407
|
+
const error = aspectDataError(entry.aspects, `entry '${(0, kc_entries_1.entryId)(entry)}'`);
|
|
408
|
+
if (error)
|
|
409
|
+
return error;
|
|
410
|
+
}
|
|
411
|
+
for (const link of model.resources.entryLinks) {
|
|
412
|
+
const error = aspectDataError(link.aspects, `entry link '${(0, kc_entries_1.linkId)(link)}'`);
|
|
413
|
+
if (error)
|
|
414
|
+
return error;
|
|
415
|
+
}
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
// Why Knowledge Catalog cannot store one of `aspects`, which `owner` carries,
|
|
419
|
+
// or undefined when each fits.
|
|
420
|
+
function aspectDataError(aspects, owner) {
|
|
421
|
+
for (const [key, aspect] of Object.entries(aspects ?? {})) {
|
|
422
|
+
const type = aspectTypeIdOfKey(key);
|
|
423
|
+
const bytes = storedJsonBytes(aspect.data ?? {});
|
|
424
|
+
const limit = type === 'schema' ? MAX_SCHEMA_ASPECT_DATA_BYTES :
|
|
425
|
+
exports.MAX_ASPECT_DATA_BYTES;
|
|
426
|
+
if (bytes > limit) {
|
|
427
|
+
return `aspect '${key}' of ${owner} is too large to store in ` +
|
|
428
|
+
`Knowledge Catalog: its data takes ${bytes} bytes, and one ` +
|
|
429
|
+
`aspect holds at most ${limit}.`;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
434
|
+
// The UTF-8 bytes of `data`'s JSON as Dataplex counts them. Dataplex writes
|
|
435
|
+
// < > & = ' U+2028 and U+2029 as six-byte \uXXXX escapes.
|
|
436
|
+
function storedJsonBytes(data) {
|
|
437
|
+
const json = JSON.stringify(data);
|
|
438
|
+
return new TextEncoder().encode(json).length +
|
|
439
|
+
5 * (json.match(/[<>&=']/g)?.length ?? 0) +
|
|
440
|
+
3 * (json.match(/[\u2028\u2029]/g)?.length ?? 0);
|
|
441
|
+
}
|
|
442
|
+
// The aspect type id in an aspect key, `<project>.<location>.<type>` with an
|
|
443
|
+
// optional `@<path>`.
|
|
444
|
+
function aspectTypeIdOfKey(key) {
|
|
445
|
+
return key.split('@')[0].split('.').pop() ?? '';
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Validates that every kcmd-owned custom type (`semantic-action`,
|
|
449
|
+
* `semantic-constraint`, `semantic-model-file`) used by `model` exists in the
|
|
450
|
+
* target project.
|
|
451
|
+
*
|
|
452
|
+
* Unlike built-in system types in `dataplex-types`, custom entry and aspect
|
|
453
|
+
* types are provisioned in the destination project by `kcmd init`;
|
|
454
|
+
* checking them up front in preflight fails fast with an actionable message
|
|
455
|
+
* before any catalog entries or entry links are written, rather than failing
|
|
456
|
+
* mid-push when creating an action or constraint entry.
|
|
457
|
+
*
|
|
458
|
+
* Skips the file-copy type when `opts.fileCopyTypeChecked` is set, because
|
|
459
|
+
* the caller has already looked it up. Makes no network calls when no type
|
|
460
|
+
* is left to check.
|
|
461
|
+
*/
|
|
462
|
+
async function validateCustomTypes(cat, model, opts, result) {
|
|
463
|
+
const typeIds = (0, kc_custom_types_1.collectCustomTypes)(model.resources.entries).filter(id => !(opts.fileCopyTypeChecked && id === kc_custom_types_1.FILE_COPY_TYPE_ID));
|
|
464
|
+
const error = await checkCustomTypes(cat, opts, typeIds);
|
|
465
|
+
return error ? result(false, error) : result(true);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Checks that each custom type in `typeIds` exists in the destination
|
|
469
|
+
* project. Returns the error to report, or undefined when every one exists.
|
|
470
|
+
*
|
|
471
|
+
* Push also calls it before any graph deploys, for the file-copy type every
|
|
472
|
+
* push with v2Aspects off needs. Push then sets `fileCopyTypeChecked`, so the
|
|
473
|
+
* catalog preflight looks up only the other custom types.
|
|
474
|
+
*/
|
|
475
|
+
async function checkCustomTypes(cat, dest, typeIds) {
|
|
476
|
+
const home = (0, kc_custom_types_1.customTypeHome)(dest);
|
|
477
|
+
for (const typeId of typeIds) {
|
|
478
|
+
const [entryTypeRes, aspectTypeRes] = await Promise.all([
|
|
479
|
+
cat.getEntryType(home.project, home.location, typeId),
|
|
480
|
+
cat.getAspectType(home.project, home.location, typeId),
|
|
481
|
+
]);
|
|
482
|
+
if (isNotFoundStatus(entryTypeRes) || isNotFoundStatus(aspectTypeRes)) {
|
|
483
|
+
return `Custom type '${typeId}' not found in project ` +
|
|
484
|
+
`'${dest.project}'; run 'kcmd init --semantic-model' first.`;
|
|
485
|
+
}
|
|
486
|
+
if (!isOkStatus(entryTypeRes)) {
|
|
487
|
+
return `checking entry type '${typeId}' ` +
|
|
488
|
+
`(HTTP ${entryTypeRes.status}): ${errText(entryTypeRes)}`;
|
|
489
|
+
}
|
|
490
|
+
if (!isOkStatus(aspectTypeRes)) {
|
|
491
|
+
return `checking aspect type '${typeId}' ` +
|
|
492
|
+
`(HTTP ${aspectTypeRes.status}): ${errText(aspectTypeRes)}`;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return undefined;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Turns every authored model into its catalog resources.
|
|
499
|
+
*
|
|
500
|
+
* Pure (no network I/O), so the dry-run plan and any generation warnings are
|
|
501
|
+
* produced even when a later write fails. A malformed GOOGLE
|
|
502
|
+
* `custom_extension` (reached via the `semantic-model` aspect) throws; report
|
|
503
|
+
* it against its document, as the BigQuery leg does, rather than letting it
|
|
504
|
+
* escape as an uncaught stack trace.
|
|
505
|
+
*/
|
|
506
|
+
function emitModels(models, opts) {
|
|
507
|
+
const emitted = [];
|
|
508
|
+
const warnings = [];
|
|
509
|
+
for (const { document, model } of models) {
|
|
510
|
+
let resources;
|
|
511
|
+
try {
|
|
512
|
+
resources = kcEmit.generateCatalogResources(model, {
|
|
513
|
+
project: opts.project,
|
|
514
|
+
location: opts.location,
|
|
515
|
+
entryGroup: opts.entryGroup,
|
|
516
|
+
systemTypeProject: opts.systemTypeProject,
|
|
517
|
+
systemTypeLocation: opts.systemTypeLocation,
|
|
518
|
+
emitExpressions: opts.emitExpressions,
|
|
519
|
+
v2Aspects: opts.v2Aspects,
|
|
520
|
+
sourceFiles: opts.sourceFiles,
|
|
521
|
+
overviews: opts.overviews,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
return {
|
|
526
|
+
emitted,
|
|
527
|
+
warnings,
|
|
528
|
+
error: `Model '${model.name}' (${document}): ${err.message || err}`,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
for (const w of resources.warnings)
|
|
532
|
+
warnings.push(`[${model.name}] ${w}`);
|
|
533
|
+
emitted.push({ model: model.name, resources });
|
|
534
|
+
}
|
|
535
|
+
return { emitted, warnings };
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Builds the full dry-run plan across all models (one block per model; see
|
|
539
|
+
* `planSummary`).
|
|
540
|
+
*/
|
|
541
|
+
function buildPlan(emitted, opts) {
|
|
542
|
+
const plan = [];
|
|
543
|
+
for (const { model, resources } of emitted) {
|
|
544
|
+
plan.push(...planSummary(model, resources, opts));
|
|
545
|
+
}
|
|
546
|
+
return plan;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Builds a human-readable summary of what a (dry-run) push would write for one
|
|
550
|
+
* model.
|
|
551
|
+
*/
|
|
552
|
+
function planSummary(model, resources, opts) {
|
|
553
|
+
const dest = `${opts.project}.${opts.location}.${opts.entryGroup}`;
|
|
554
|
+
const lines = [
|
|
555
|
+
`Knowledge Catalog plan for '${model}' (destination ${dest}):`,
|
|
556
|
+
` ${resources.entries.length} entr${resources.entries.length === 1 ? 'y' : 'ies'}:`,
|
|
557
|
+
...resources.entries.map(e => ` - ${(0, kc_entries_1.entryId)(e)} (${(0, kc_entries_1.entryTypeId)(e)})`),
|
|
558
|
+
];
|
|
559
|
+
const linksByType = new Map();
|
|
560
|
+
for (const link of resources.entryLinks) {
|
|
561
|
+
const type = (0, kc_entries_1.linkTypeId)(link);
|
|
562
|
+
linksByType.set(type, [...(linksByType.get(type) ?? []), (0, kc_entries_1.linkId)(link)]);
|
|
563
|
+
}
|
|
564
|
+
for (const [type, ids] of linksByType) {
|
|
565
|
+
lines.push(` ${ids.length} ${type} link${ids.length === 1 ? '' : 's'}:`, ...ids.map(id => ` - ${id}`));
|
|
566
|
+
}
|
|
567
|
+
return lines;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Writes every model's entries and relationship links and deletes orphaned
|
|
571
|
+
* resources in model order.
|
|
572
|
+
*
|
|
573
|
+
* For each model, the method performs the following actions:
|
|
574
|
+
* 1. Creates or updates the model's entries (anchor first).
|
|
575
|
+
* 2. Looks up the model's existing relationship links when the pre-write
|
|
576
|
+
* entry group contains entities owned by the model.
|
|
577
|
+
* 3. Deletes any orphaned relationship links (no longer emitted) and stale
|
|
578
|
+
* relationship links (changed type or endpoints) owned by the model.
|
|
579
|
+
* Before the first delete, reads each emitted link ID the lookup did not
|
|
580
|
+
* return, and fails when push may not change the link under it.
|
|
581
|
+
* 4. Creates or updates the model's relationship links.
|
|
582
|
+
* 5. Deletes any existing entries owned by the model that are no longer
|
|
583
|
+
* emitted.
|
|
584
|
+
*
|
|
585
|
+
* Progress accumulates into `counts`, so a mid-way failure still reports what
|
|
586
|
+
* had been written.
|
|
587
|
+
*/
|
|
588
|
+
async function writeEmittedModels(cat, opts, emitted, existing, counts, isScopeProject) {
|
|
589
|
+
for (const { model, resources } of emitted) {
|
|
590
|
+
const entries = await writeEntries(cat, opts, resources.entries, existing);
|
|
591
|
+
counts.created += entries.created;
|
|
592
|
+
counts.updated += entries.updated;
|
|
593
|
+
if (entries.error)
|
|
594
|
+
return { error: `Model '${model}': ${entries.error}` };
|
|
595
|
+
// When the pre-write listing contains entity entries this model owns, look
|
|
596
|
+
// up the model's existing links once and share the map between
|
|
597
|
+
// findOrphanedEntryLinks (to delete dropped or renamed links) and
|
|
598
|
+
// findStaleEntryLinks (to delete links whose type or endpoints changed).
|
|
599
|
+
// Look up links via the model's entity entries known to the server (the
|
|
600
|
+
// pre-write snapshot `existing`), not the ones this push emits: only a
|
|
601
|
+
// server-side entry can already carry a link, and enumerating the snapshot
|
|
602
|
+
// also reaches a link both of whose endpoints were removed in this push
|
|
603
|
+
// (neither is re-emitted, but both entries are still present in `existing`
|
|
604
|
+
// until `deleteEntries` deletes them at the end). On a first push there are
|
|
605
|
+
// no such entries, so no lookup is made.
|
|
606
|
+
const existingEntityNames = resources.entries.length ?
|
|
607
|
+
(0, kc_entries_1.collectEntityNames)((0, kc_entries_1.collectOwnedEntries)(resources, existing)) :
|
|
608
|
+
[];
|
|
609
|
+
let existingLinks = new Map();
|
|
610
|
+
if (existingEntityNames.length) {
|
|
611
|
+
const lookedUp = await lookupEntryLinks(cat, opts, existingEntityNames, isScopeProject);
|
|
612
|
+
if (lookedUp.error) {
|
|
613
|
+
const { entryId, message } = lookedUp.error;
|
|
614
|
+
return {
|
|
615
|
+
error: `Looking up entry links for '${entryId}' from model '${model}' failed: ${message}`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
existingLinks = lookedUp.links;
|
|
619
|
+
}
|
|
620
|
+
// Find orphaned links (dropped or renamed) and stale links (same ID,
|
|
621
|
+
// changed type or endpoints) in `existingLinks` and delete both BEFORE
|
|
622
|
+
// creating current links so renamed links, swapped endpoint pairs, and
|
|
623
|
+
// taken-over endpoint pairs do not collide with an existing link.
|
|
624
|
+
const orphanedLinks = findOrphanedEntryLinks(resources, existingLinks);
|
|
625
|
+
const staleLinks = findStaleEntryLinks(resources, existingLinks);
|
|
626
|
+
if (staleLinks.error) {
|
|
627
|
+
return { error: `Model '${model}': ${staleLinks.error}` };
|
|
628
|
+
}
|
|
629
|
+
// Before a link is deleted, read each emitted ID the lookup did not
|
|
630
|
+
// return. A link under one of them that push may not change would fail
|
|
631
|
+
// its write after the delete and leave the relationship with no link.
|
|
632
|
+
const foundUnchanged = new Set();
|
|
633
|
+
if (orphanedLinks.length || staleLinks.links.length) {
|
|
634
|
+
for (const link of resources.entryLinks) {
|
|
635
|
+
if (existingLinks.has((0, kc_entries_1.linkId)(link)))
|
|
636
|
+
continue;
|
|
637
|
+
const read = await readEmittedLink(cat, opts, resources, link, isScopeProject)
|
|
638
|
+
.catch((err) => ({
|
|
639
|
+
error: `entry link '${(0, kc_entries_1.linkId)(link)}': ${String(err?.message || err).trim()}`,
|
|
640
|
+
}));
|
|
641
|
+
if (read.error)
|
|
642
|
+
return { error: `Model '${model}': ${read.error}` };
|
|
643
|
+
if (read.existing && !isStaleLink(read.existing, link)) {
|
|
644
|
+
foundUnchanged.add((0, kc_entries_1.linkId)(link));
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
// `deletedLinks` collects the links deleted so far. An error after a
|
|
649
|
+
// delete gets the push-again hint when a link is missing because of it.
|
|
650
|
+
const deletedLinks = [];
|
|
651
|
+
const hint = (written = new Set()) => restoreHint(deletedLinks, resources.entryLinks, new Set([...staleLinks.current, ...foundUnchanged, ...written]));
|
|
652
|
+
const orphanedLinkDeletions = await deleteEntryLinks(cat, opts, orphanedLinks);
|
|
653
|
+
counts.unlinked += orphanedLinkDeletions.deleted.length;
|
|
654
|
+
deletedLinks.push(...orphanedLinkDeletions.deleted);
|
|
655
|
+
if (orphanedLinkDeletions.error) {
|
|
656
|
+
const { linkId, message } = orphanedLinkDeletions.error;
|
|
657
|
+
return {
|
|
658
|
+
error: `Deleting orphaned entry link '${linkId}' from model '${model}' failed: ${message}${hint()}`,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
const staleLinkDeletions = await deleteEntryLinks(cat, opts, staleLinks.links);
|
|
662
|
+
deletedLinks.push(...staleLinkDeletions.deleted);
|
|
663
|
+
if (staleLinkDeletions.error) {
|
|
664
|
+
const { linkId, message } = staleLinkDeletions.error;
|
|
665
|
+
return {
|
|
666
|
+
error: `Deleting stale entry link '${linkId}' from model '${model}' failed: ${message}${hint()}`,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
// Links reference this model's entity entries, so they follow the entries
|
|
670
|
+
// above (both endpoints must exist first).
|
|
671
|
+
const links = await writeEntryLinks(cat, opts, resources, staleLinks.current, isScopeProject);
|
|
672
|
+
counts.linked += links.linked;
|
|
673
|
+
deletedLinks.push(...links.deleted);
|
|
674
|
+
if (links.error) {
|
|
675
|
+
return { error: `Model '${model}': ${links.error}${hint(links.written)}` };
|
|
676
|
+
}
|
|
677
|
+
// Finally, delete any entry this model owns but no longer emits.
|
|
678
|
+
const orphanedEntries = findOrphanedEntries(resources, existing);
|
|
679
|
+
const deleted = await deleteEntries(cat, opts, orphanedEntries);
|
|
680
|
+
counts.deleted += deleted.deleted;
|
|
681
|
+
if (deleted.error) {
|
|
682
|
+
const { entryId, message } = deleted.error;
|
|
683
|
+
return {
|
|
684
|
+
error: `Deleting orphaned entry '${entryId}' from model '${model}' failed: ${message}`,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return {};
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Returns the push-again hint for an error, or '' when no link is missing.
|
|
692
|
+
*
|
|
693
|
+
* An emitted link is missing when this push deleted a link with its ID or its
|
|
694
|
+
* two entries, and `presentIds` does not hold it. `presentIds` holds the IDs
|
|
695
|
+
* of the links written or found unchanged.
|
|
696
|
+
*/
|
|
697
|
+
function restoreHint(deletedLinks, emittedLinks, presentIds) {
|
|
698
|
+
const ends = (link) => link.entryReferences.map(r => (0, kc_entries_1.entryId)(r)).sort().join('|');
|
|
699
|
+
const missing = emittedLinks.some(link => !presentIds.has((0, kc_entries_1.linkId)(link)) &&
|
|
700
|
+
deletedLinks.some(d => (0, kc_entries_1.linkId)(d) === (0, kc_entries_1.linkId)(link) || ends(d) === ends(link)));
|
|
701
|
+
return missing ? ' (push again to restore the link)' : '';
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Returns the entries in `existing` that `resources` owns but no longer emits.
|
|
705
|
+
*
|
|
706
|
+
* An entry becomes orphaned when a child resource (such as an entity, metric,
|
|
707
|
+
* action, constraint, or explore) is deleted or renamed in the model since the
|
|
708
|
+
* last push. Only child entries under `resources.ownedPrefixes` are ever
|
|
709
|
+
* touched: entries outside those prefixes and the model anchor (always
|
|
710
|
+
* re-emitted) are never deleted here.
|
|
711
|
+
*
|
|
712
|
+
* Deleting a whole model is handled by the `--force-remove` guard, and orphaned
|
|
713
|
+
* relationship links by `findOrphanedEntryLinks` and `deleteEntryLinks`. This
|
|
714
|
+
* step reads the pre-write snapshot `existing`, so it issues no list call of
|
|
715
|
+
* its own (a re-emitted entry is never a deletion candidate, so the snapshot
|
|
716
|
+
* stays correct).
|
|
717
|
+
*/
|
|
718
|
+
function findOrphanedEntries(resources, existing) {
|
|
719
|
+
// An emitter that produced no entries has no anchor and owns nothing.
|
|
720
|
+
if (!resources.entries.length)
|
|
721
|
+
return [];
|
|
722
|
+
const emittedIds = new Set(resources.entries.map(kc_entries_1.entryId));
|
|
723
|
+
return existing.filter(e => (0, kc_entries_1.isEntryOwner)(resources, e) && !emittedIds.has((0, kc_entries_1.entryId)(e)));
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Deletes every entry in `entries`.
|
|
727
|
+
*
|
|
728
|
+
* A 404 response on delete counts as success because the entry is already gone.
|
|
729
|
+
*/
|
|
730
|
+
async function deleteEntries(cat, opts, entries) {
|
|
731
|
+
let deleted = 0;
|
|
732
|
+
for (const entry of entries) {
|
|
733
|
+
const id = (0, kc_entries_1.entryId)(entry);
|
|
734
|
+
const res = await cat.deleteEntry(opts.project, opts.location, opts.entryGroup, id);
|
|
735
|
+
// A 404 means it is already gone -- reconciliation's goal is met.
|
|
736
|
+
if (isOkStatus(res) || isNotFoundStatus(res)) {
|
|
737
|
+
deleted++;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
return { deleted, error: { entryId: id, message: errText(res) } };
|
|
741
|
+
}
|
|
742
|
+
return { deleted };
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Snapshots the destination entry group's entries once, before any write.
|
|
746
|
+
*
|
|
747
|
+
* A brand-new entry group can briefly fail to list its entries collection (the
|
|
748
|
+
* same propagation window `createEntry` rides out); treat only that
|
|
749
|
+
* not-yet-visible error as empty so any other listing failure (such as a
|
|
750
|
+
* backend error or permission problem) is surfaced rather than masked.
|
|
751
|
+
*/
|
|
752
|
+
async function listEntryGroup(cat, opts) {
|
|
753
|
+
const entries = [];
|
|
754
|
+
try {
|
|
755
|
+
// Push writes listed source fields back, so it keeps each `resource` as
|
|
756
|
+
// stored.
|
|
757
|
+
for await (const entry of cat.listEntries(opts.project, opts.location, opts.entryGroup, { keepSourceResource: true })) {
|
|
758
|
+
entries.push(entry);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
catch (err) {
|
|
762
|
+
const msg = err.message || String(err);
|
|
763
|
+
if (isPropagationError({ message: msg })) {
|
|
764
|
+
return { entries: [] };
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
entries: [],
|
|
768
|
+
error: `listing entries in entry group '${opts.entryGroup}': ${msg}`,
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
return { entries };
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Looks up all model links in the destination entry group referencing any of
|
|
775
|
+
* `entityNames`.
|
|
776
|
+
*
|
|
777
|
+
* Links are looked up per referenced entry -- the only server-side access path
|
|
778
|
+
* -- so a link between two of the entities is returned twice and deduplicated
|
|
779
|
+
* here by bare link ID. Queries `lookupEntryLinks` and filters the results to
|
|
780
|
+
* the kinds of link push writes (`isModelLink`) stored in the destination
|
|
781
|
+
* entry group
|
|
782
|
+
* (`projects/<project>/locations/<location>/entryGroups/<entryGroup>/entryLinks/`).
|
|
783
|
+
* `isScopeProject` tells whether a link's project part names the destination
|
|
784
|
+
* project. A link whose project number push cannot resolve is kept.
|
|
785
|
+
* `isLinkOwner` accepts such a link only when its references spell the same
|
|
786
|
+
* project as its name, so no reference in another project counts as the
|
|
787
|
+
* model's own.
|
|
788
|
+
*/
|
|
789
|
+
async function lookupEntryLinks(cat, opts, entityNames, isScopeProject) {
|
|
790
|
+
const groupRegExp = new RegExp(`^projects/([^/]+)/locations/${opts.location}/entryGroups/${opts.entryGroup}/entryLinks/`);
|
|
791
|
+
const links = new Map();
|
|
792
|
+
for (const entityName of entityNames) {
|
|
793
|
+
const res = await cat.lookupEntryLinks(opts.project, opts.location, { entry: entityName });
|
|
794
|
+
if (!isOkStatus(res)) {
|
|
795
|
+
return {
|
|
796
|
+
links,
|
|
797
|
+
error: { entryId: (0, kc_ids_1.entryIdOf)(entityName), message: errText(res) },
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
for (const link of res.result ?? []) {
|
|
801
|
+
const project = link.name?.match(groupRegExp)?.[1];
|
|
802
|
+
if (!project || (await isScopeProject(project)) === false)
|
|
803
|
+
continue;
|
|
804
|
+
if (!(0, kc_entries_1.isModelLink)(link))
|
|
805
|
+
continue;
|
|
806
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
807
|
+
if (!links.has(id))
|
|
808
|
+
links.set(id, link);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return { links };
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Returns a function that tells whether the project part of a resource name
|
|
815
|
+
* that Dataplex returned names the destination project, `project`.
|
|
816
|
+
*
|
|
817
|
+
* Dataplex spells link references, and the names of looked-up links, with the
|
|
818
|
+
* project number. When `project` is a project ID, the number comes from Cloud
|
|
819
|
+
* Resource Manager. When Resource Manager does not give it, the function
|
|
820
|
+
* resolves to undefined for every other project number.
|
|
821
|
+
*/
|
|
822
|
+
function scopeProjectMatcher(ctx, project) {
|
|
823
|
+
let crmNumber;
|
|
824
|
+
return async (nameProject) => {
|
|
825
|
+
// Check whether `nameProject` refers to the destination `project`:
|
|
826
|
+
// 1. If `nameProject === project`, they already match verbatim.
|
|
827
|
+
// 2. If `nameProject` is not a numeric project number, it is a different
|
|
828
|
+
// project's string ID and cannot match `project`. Only a numeric
|
|
829
|
+
// `nameProject` can still refer to `project` (when `project` is a
|
|
830
|
+
// project ID and Dataplex returned `nameProject` as a project number).
|
|
831
|
+
// 3. Otherwise, resolve `project`'s number via Cloud Resource Manager
|
|
832
|
+
// (caching the Promise in `crmNumber` so it is called at most once)
|
|
833
|
+
// and compare `nameProject` against it. Without the number, the
|
|
834
|
+
// answer is unknown.
|
|
835
|
+
if (nameProject === project)
|
|
836
|
+
return true;
|
|
837
|
+
if (!/^\d+$/.test(nameProject))
|
|
838
|
+
return false;
|
|
839
|
+
const number = await (crmNumber ??= crm.projectNumber(project, ctx));
|
|
840
|
+
return number === undefined ? undefined : nameProject === number;
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Deletes every entry link in `links`.
|
|
845
|
+
*
|
|
846
|
+
* A 404 response on delete counts as success because the link is already gone.
|
|
847
|
+
* A thrown error, such as a network failure, is returned like a failed
|
|
848
|
+
* response, so the caller can name the link and keep its counts.
|
|
849
|
+
*/
|
|
850
|
+
async function deleteEntryLinks(cat, opts, links) {
|
|
851
|
+
const deleted = [];
|
|
852
|
+
for (const link of links) {
|
|
853
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
854
|
+
let del;
|
|
855
|
+
try {
|
|
856
|
+
del = await cat.deleteEntryLink(opts.project, opts.location, opts.entryGroup, id);
|
|
857
|
+
}
|
|
858
|
+
catch (err) {
|
|
859
|
+
const message = String(err?.message || err).trim();
|
|
860
|
+
return { deleted, error: { linkId: id, message } };
|
|
861
|
+
}
|
|
862
|
+
if (isOkStatus(del) || isNotFoundStatus(del)) {
|
|
863
|
+
deleted.push(link);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
return { deleted, error: { linkId: id, message: errText(del) } };
|
|
867
|
+
}
|
|
868
|
+
return { deleted };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Returns the relationship entry links in `existingLinks` that `resources`
|
|
872
|
+
* owns but no longer emits.
|
|
873
|
+
*
|
|
874
|
+
* An entry link becomes orphaned when a relationship is deleted or renamed in
|
|
875
|
+
* the model since the last push, or when a relationship stops being published
|
|
876
|
+
* (for example, if one of its endpoint entities is removed). A link touching an
|
|
877
|
+
* entry outside `resources` is never treated as owned.
|
|
878
|
+
*/
|
|
879
|
+
function findOrphanedEntryLinks(resources, existingLinks) {
|
|
880
|
+
// An emitter that produced no entries has no anchor and owns nothing.
|
|
881
|
+
if (!resources.entries.length || !existingLinks.size)
|
|
882
|
+
return [];
|
|
883
|
+
const emittedLinkIds = new Set(resources.entryLinks.map(kc_entries_1.linkId));
|
|
884
|
+
return [...existingLinks.values()].filter(link => (0, kc_entries_1.isLinkOwner)(resources, link) && !emittedLinkIds.has((0, kc_entries_1.linkId)(link)));
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Returns the existing entry links in `existingLinks` that share a bare link ID
|
|
888
|
+
* with an emitted link in `resources.entryLinks` but have a different link type
|
|
889
|
+
* or endpoint references, so they must be deleted before recreating the link.
|
|
890
|
+
*
|
|
891
|
+
* Fails with `error` (before any link is deleted) if a matching existing link
|
|
892
|
+
* references an entry outside `resources`.
|
|
893
|
+
*/
|
|
894
|
+
function findStaleEntryLinks(resources, existingLinks) {
|
|
895
|
+
const links = [];
|
|
896
|
+
const current = new Set();
|
|
897
|
+
if (!existingLinks.size)
|
|
898
|
+
return { links, current };
|
|
899
|
+
for (const link of resources.entryLinks) {
|
|
900
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
901
|
+
const existingLink = existingLinks.get(id);
|
|
902
|
+
// Check whether `existingLink` is stale and safe to delete:
|
|
903
|
+
// 1. If no link with this ID exists yet, `writeEntryLinks` creates it.
|
|
904
|
+
// 2. If an existing link with this ID references an entry outside this
|
|
905
|
+
// model, we do not own it and must fail before deleting anything.
|
|
906
|
+
// 3. If we own it and its link type and endpoint references are
|
|
907
|
+
// unchanged, `writeEntryLinks` updates its aspects in place.
|
|
908
|
+
// Otherwise, its immutable type or endpoints changed, so it is stale.
|
|
909
|
+
if (!existingLink)
|
|
910
|
+
continue;
|
|
911
|
+
if (!(0, kc_entries_1.isLinkOwner)(resources, existingLink)) {
|
|
912
|
+
return {
|
|
913
|
+
links: [],
|
|
914
|
+
current,
|
|
915
|
+
error: outsideReferenceError(resources, existingLink),
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
if (isStaleLink(existingLink, link)) {
|
|
919
|
+
links.push(existingLink);
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
current.add(id);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return { links, current };
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Returns true when `existingLink` has a different immutable `entryLinkType`
|
|
929
|
+
* or `entryReferences` from `link` and must be deleted and recreated rather
|
|
930
|
+
* than updated in place.
|
|
931
|
+
*/
|
|
932
|
+
function isStaleLink(existingLink, link) {
|
|
933
|
+
return (0, kc_entries_1.linkTypeId)(existingLink) !== (0, kc_entries_1.linkTypeId)(link) ||
|
|
934
|
+
!(0, kc_entries_1.sameLinkReferences)(existingLink, link);
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Returns the error for an existing link that holds an ID this model emits and
|
|
938
|
+
* references an entry outside the model. Push does not delete or change such
|
|
939
|
+
* a link.
|
|
940
|
+
*/
|
|
941
|
+
function outsideReferenceError(resources, link, asReturned = link) {
|
|
942
|
+
// A reference whose entry ID this model owns is outside the model because it
|
|
943
|
+
// names an entry in another entry group or project, so the error gives its
|
|
944
|
+
// full name, as Dataplex returned it. `asReturned` is `link` before push
|
|
945
|
+
// respelled its project.
|
|
946
|
+
const ownedReferences = (0, kc_entries_1.collectOwnedReferences)(resources, link);
|
|
947
|
+
const externalIds = [];
|
|
948
|
+
link.entryReferences.forEach((r, i) => {
|
|
949
|
+
if (ownedReferences.includes(r))
|
|
950
|
+
return;
|
|
951
|
+
const shown = (0, kc_entries_1.isEntryOwner)(resources, r) ?
|
|
952
|
+
asReturned.entryReferences[i].name :
|
|
953
|
+
(0, kc_entries_1.entryId)(r);
|
|
954
|
+
externalIds.push(`'${shown}'`);
|
|
955
|
+
});
|
|
956
|
+
return `entry link '${(0, kc_entries_1.linkId)(link)}' already exists and references ${externalIds.length === 1 ? 'entry' : 'entries'} ${externalIds.join(' and ')} outside this model; ` +
|
|
957
|
+
`rename the relationship or delete the existing link`;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Deletes models already in the entry group that this push does not re-emit
|
|
961
|
+
* (`--force-remove`).
|
|
962
|
+
*
|
|
963
|
+
* For each foreign anchor, looks up its relationship links and then deletes
|
|
964
|
+
* the anchor before anything else. Dataplex deletes an entry's child entries
|
|
965
|
+
* with it, and a link with an entry it references, so a push that stops here
|
|
966
|
+
* leaves the whole foreign model or none of it. Pull then returns the whole
|
|
967
|
+
* model or finds none. The links and the children present in `existing` are
|
|
968
|
+
* deleted after the anchor, and a 404 for one already gone counts as success.
|
|
969
|
+
*
|
|
970
|
+
* A foreign model is by definition not in this push, so its emitter's
|
|
971
|
+
* `ownedPrefixes` are unavailable. Ownership is instead the anchor followed by
|
|
972
|
+
* a separator (`.` or `/`), which matches every ID scheme
|
|
973
|
+
* (`<anchor>.entities.x` and `<anchor>/entities/x`) without hardcoding segment
|
|
974
|
+
* names. Naming the segments here would leave a model published by a different
|
|
975
|
+
* emitter with its children orphaned and unreachable -- the anchor goes, so no
|
|
976
|
+
* later push sees a foreign model, and nothing owns the remainder.
|
|
977
|
+
*/
|
|
978
|
+
async function removeForeignModels(cat, opts, existing, foreignAnchorIds, isScopeProject) {
|
|
979
|
+
let deleted = 0;
|
|
980
|
+
let unlinked = 0;
|
|
981
|
+
for (const foreignAnchorId of foreignAnchorIds) {
|
|
982
|
+
// Find the foreign model's anchor entry and all of its child entries
|
|
983
|
+
// across both the V1 dotted (`<anchor>.`) and V2/LookML slash (`<anchor>/`)
|
|
984
|
+
// namespaces, then extract the full resource names of its `semantic-entity`
|
|
985
|
+
// entries so we can look up their relationship links.
|
|
986
|
+
const foreignAnchor = {
|
|
987
|
+
anchorId: foreignAnchorId,
|
|
988
|
+
ownedPrefixes: [`${foreignAnchorId}.`, `${foreignAnchorId}/`],
|
|
989
|
+
};
|
|
990
|
+
const foreignEntries = (0, kc_entries_1.collectOwnedEntries)(foreignAnchor, existing);
|
|
991
|
+
const foreignEntityNames = (0, kc_entries_1.collectEntityNames)(foreignEntries);
|
|
992
|
+
// Look the links up while their entity entries still exist.
|
|
993
|
+
const lookedUp = await lookupEntryLinks(cat, opts, foreignEntityNames, isScopeProject);
|
|
994
|
+
if (lookedUp.error) {
|
|
995
|
+
const { entryId, message } = lookedUp.error;
|
|
996
|
+
return {
|
|
997
|
+
deleted,
|
|
998
|
+
unlinked,
|
|
999
|
+
error: `Looking up entry links for '${entryId}' from removed model '${foreignAnchorId}' failed: ${message}`,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
// The anchor goes first and takes its children and their links with it.
|
|
1003
|
+
// A failure before it leaves the foreign model whole. The deletes after it
|
|
1004
|
+
// remove anything Dataplex left and normally get 404.
|
|
1005
|
+
const entryFailure = (e) => ({
|
|
1006
|
+
deleted,
|
|
1007
|
+
unlinked,
|
|
1008
|
+
error: `Deleting entry '${e.entryId}' from removed model '${foreignAnchorId}' failed: ${e.message}`,
|
|
1009
|
+
});
|
|
1010
|
+
const anchors = await deleteEntries(cat, opts, foreignEntries.filter(kc_entries_1.isAnchorEntry));
|
|
1011
|
+
deleted += anchors.deleted;
|
|
1012
|
+
if (anchors.error)
|
|
1013
|
+
return entryFailure(anchors.error);
|
|
1014
|
+
const links = await deleteEntryLinks(cat, opts, lookedUp.links.values());
|
|
1015
|
+
unlinked += links.deleted.length;
|
|
1016
|
+
if (links.error) {
|
|
1017
|
+
const { linkId, message } = links.error;
|
|
1018
|
+
return {
|
|
1019
|
+
deleted,
|
|
1020
|
+
unlinked,
|
|
1021
|
+
error: `Deleting entry link '${linkId}' from removed model '${foreignAnchorId}' failed: ${message}`,
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
const children = await deleteEntries(cat, opts, foreignEntries.filter(e => !(0, kc_entries_1.isAnchorEntry)(e)));
|
|
1025
|
+
deleted += children.deleted;
|
|
1026
|
+
if (children.error)
|
|
1027
|
+
return entryFailure(children.error);
|
|
1028
|
+
}
|
|
1029
|
+
return { deleted, unlinked };
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Creates or updates a model's entries in dependency order.
|
|
1033
|
+
*
|
|
1034
|
+
* The method writes entries in three stages:
|
|
1035
|
+
* 1. Writes the model anchor (`entries[0]`) first, since it is the parent of
|
|
1036
|
+
* every child entry.
|
|
1037
|
+
* 2. Writes independent child entries (such as entities, relationships,
|
|
1038
|
+
* actions, and constraints) concurrently.
|
|
1039
|
+
* 3. Writes dependent child entries (`semantic-metric` via
|
|
1040
|
+
* `semantic-metric.entity` and, for LookML, `semantic-explore` via
|
|
1041
|
+
* `semantic-explore.baseEntity` / `joins[].fromEntity`, which reference
|
|
1042
|
+
* entities by name) concurrently.
|
|
1043
|
+
*
|
|
1044
|
+
* An entry that already exists is updated in place (idempotent re-push).
|
|
1045
|
+
*/
|
|
1046
|
+
async function writeEntries(cat, opts, entries, existing = []) {
|
|
1047
|
+
if (!entries.length)
|
|
1048
|
+
return { created: 0, updated: 0 };
|
|
1049
|
+
// The entrySource each entry had before this push. An update keeps the
|
|
1050
|
+
// fields kcmd does not own.
|
|
1051
|
+
const sources = new Map(existing.map(e => [(0, kc_entries_1.entryId)(e), e.entrySource]));
|
|
1052
|
+
const write = (e) => writeEntry(cat, opts, e, sources);
|
|
1053
|
+
const [anchor, ...children] = entries;
|
|
1054
|
+
const anchorRes = await write(anchor);
|
|
1055
|
+
if (anchorRes.error)
|
|
1056
|
+
return { created: 0, updated: 0, error: anchorRes.error };
|
|
1057
|
+
let created = anchorRes.updated ? 0 : 1;
|
|
1058
|
+
let updated = anchorRes.updated ? 1 : 0;
|
|
1059
|
+
const independentEntries = children.filter(e => !(0, kc_entries_1.isDependentEntry)(e));
|
|
1060
|
+
const dependentEntries = children.filter(kc_entries_1.isDependentEntry);
|
|
1061
|
+
for (const entryWave of [independentEntries, dependentEntries]) {
|
|
1062
|
+
const res = await Promise.all(entryWave.map(write));
|
|
1063
|
+
for (const r of res) {
|
|
1064
|
+
if (r.error)
|
|
1065
|
+
continue;
|
|
1066
|
+
if (r.updated) {
|
|
1067
|
+
updated++;
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
created++;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const firstErr = res.find(r => r.error);
|
|
1074
|
+
if (firstErr)
|
|
1075
|
+
return { created, updated, error: firstErr.error };
|
|
1076
|
+
}
|
|
1077
|
+
return { created, updated };
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Writes a model's relationship entry links concurrently.
|
|
1081
|
+
*
|
|
1082
|
+
* Both endpoint entries already exist because `writeEntries` runs first, and
|
|
1083
|
+
* any orphaned or stale links the lookup found have already been deleted. On
|
|
1084
|
+
* the flag-off path the emitter gives each pair of entries at most one link,
|
|
1085
|
+
* and with the flag on, each ordered pair. A stale link the lookup missed is
|
|
1086
|
+
* replaced during these writes, so a link that needs the pair it frees can fail
|
|
1087
|
+
* with the duplicate-endpoints error. That error gets the push-again hint, and
|
|
1088
|
+
* the next push writes the link. Uses `Promise.allSettled` so a thrown network
|
|
1089
|
+
* error on one link waits for any in-flight link writes to finish and reports
|
|
1090
|
+
* the link ID in the returned error. `current` holds the emitted IDs whose
|
|
1091
|
+
* looked-up link is the model's own with the same type and endpoints.
|
|
1092
|
+
* `isScopeProject` tells whether a project part names the destination project.
|
|
1093
|
+
*/
|
|
1094
|
+
async function writeEntryLinks(cat, opts, resources, current, isScopeProject) {
|
|
1095
|
+
const entryLinks = resources.entryLinks;
|
|
1096
|
+
const written = new Set();
|
|
1097
|
+
const deleted = [];
|
|
1098
|
+
if (!entryLinks.length)
|
|
1099
|
+
return { linked: 0, written, deleted };
|
|
1100
|
+
const settled = await Promise.allSettled(entryLinks.map(l => writeEntryLink(cat, opts, resources, l, current, isScopeProject)));
|
|
1101
|
+
const res = settled.map((s, i) => {
|
|
1102
|
+
if (s.status === 'fulfilled')
|
|
1103
|
+
return s.value;
|
|
1104
|
+
const id = (0, kc_entries_1.linkId)(entryLinks[i]);
|
|
1105
|
+
const msg = s.reason?.message || String(s.reason);
|
|
1106
|
+
return { error: `entry link '${id}': ${msg}` };
|
|
1107
|
+
});
|
|
1108
|
+
res.forEach((r, i) => {
|
|
1109
|
+
if (r.deleted)
|
|
1110
|
+
deleted.push(r.deleted);
|
|
1111
|
+
if (!r.error)
|
|
1112
|
+
written.add((0, kc_entries_1.linkId)(entryLinks[i]));
|
|
1113
|
+
});
|
|
1114
|
+
const linked = res.filter(r => !r.error).length;
|
|
1115
|
+
const firstErr = res.find(r => r.error);
|
|
1116
|
+
if (firstErr)
|
|
1117
|
+
return { linked, written, deleted, error: firstErr.error };
|
|
1118
|
+
return { linked, written, deleted };
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Writes a single relationship entry link to Knowledge Catalog.
|
|
1122
|
+
*
|
|
1123
|
+
* Attempts to create `link`. When `createEntryLink` returns HTTP 409, updates
|
|
1124
|
+
* the link under its ID, replaces it, or reports why push cannot write it.
|
|
1125
|
+
* `current` holds the emitted IDs whose looked-up link is the model's own with
|
|
1126
|
+
* the same type and endpoints.
|
|
1127
|
+
*/
|
|
1128
|
+
async function writeEntryLink(cat, opts, resources, link, current, isScopeProject) {
|
|
1129
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
1130
|
+
const res = await cat.createEntryLink(opts.project, opts.location, opts.entryGroup, id, link);
|
|
1131
|
+
if (!isExistsStatus(res))
|
|
1132
|
+
return linkOutcome(link, res);
|
|
1133
|
+
if (current.has(id)) {
|
|
1134
|
+
return linkOutcome(link, await updateEntryLink(cat, link));
|
|
1135
|
+
}
|
|
1136
|
+
// The lookup did not return a link under this ID, so read it.
|
|
1137
|
+
const read = await readEmittedLink(cat, opts, resources, link, isScopeProject);
|
|
1138
|
+
if (read.error)
|
|
1139
|
+
return { error: read.error };
|
|
1140
|
+
if (!read.existing) {
|
|
1141
|
+
// No link holds the ID. Dataplex allows one link of a type between two
|
|
1142
|
+
// entries, so the 409 means another link already joins them.
|
|
1143
|
+
return { error: duplicateEndpointsError(link, errText(res)) };
|
|
1144
|
+
}
|
|
1145
|
+
// The update patches only the aspects, and a link's type and references are
|
|
1146
|
+
// immutable. The model's own link whose type or endpoints changed is
|
|
1147
|
+
// deleted and created again.
|
|
1148
|
+
if (isStaleLink(read.existing, link)) {
|
|
1149
|
+
return replaceEntryLink(cat, opts, read.existing, link);
|
|
1150
|
+
}
|
|
1151
|
+
return linkOutcome(link, await updateEntryLink(cat, link));
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Reads the link under the ID of the emitted link `link` and checks that push
|
|
1155
|
+
* may change it.
|
|
1156
|
+
*
|
|
1157
|
+
* Returns the error when the read fails with anything but 404, when the link
|
|
1158
|
+
* is not of a kind push writes, when it references an entry outside the model,
|
|
1159
|
+
* and when push cannot resolve the project number its references need.
|
|
1160
|
+
*/
|
|
1161
|
+
async function readEmittedLink(cat, opts, resources, link, isScopeProject) {
|
|
1162
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
1163
|
+
const found = await cat.getEntryLink(link.name);
|
|
1164
|
+
if (isNotFoundStatus(found))
|
|
1165
|
+
return {};
|
|
1166
|
+
if (!isOkStatus(found) || !found.result) {
|
|
1167
|
+
return { error: `reading entry link '${id}' failed: ${errText(found)}` };
|
|
1168
|
+
}
|
|
1169
|
+
const existing = await respellScopeProject(found.result, opts.project, isScopeProject);
|
|
1170
|
+
if (!existing) {
|
|
1171
|
+
return {
|
|
1172
|
+
error: `entry link '${id}' already exists, and push could not get the ` +
|
|
1173
|
+
`number of project '${opts.project}' from Cloud Resource Manager ` +
|
|
1174
|
+
`to check its references; check that the caller has ` +
|
|
1175
|
+
`resourcemanager.projects.get on the project`,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
// Push manages only the kinds of link it writes, as the lookup does.
|
|
1179
|
+
if (!(0, kc_entries_1.isModelLink)(existing)) {
|
|
1180
|
+
return {
|
|
1181
|
+
error: `entry link '${id}' already exists with link type '${(0, kc_entries_1.linkTypeId)(existing)}'; ` +
|
|
1182
|
+
`rename the relationship or delete the existing link`,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
if (!(0, kc_entries_1.isLinkOwner)(resources, existing)) {
|
|
1186
|
+
return { error: outsideReferenceError(resources, existing, found.result) };
|
|
1187
|
+
}
|
|
1188
|
+
return { existing };
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Returns the outcome of a write of `link` that returned `res`.
|
|
1192
|
+
*/
|
|
1193
|
+
function linkOutcome(link, res) {
|
|
1194
|
+
return isOkStatus(res) ? {} :
|
|
1195
|
+
{ error: `entry link '${(0, kc_entries_1.linkId)(link)}': ${errText(res)}` };
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Returns `link` with the destination project spelled as `project` in its name
|
|
1199
|
+
* and references, or undefined when a project number in them cannot be
|
|
1200
|
+
* resolved.
|
|
1201
|
+
*
|
|
1202
|
+
* Dataplex spells a read link's name as requested and its references with the
|
|
1203
|
+
* project number. `isLinkOwner` compares the two as text, so they must spell
|
|
1204
|
+
* the project the same way.
|
|
1205
|
+
*/
|
|
1206
|
+
async function respellScopeProject(link, project, isScopeProject) {
|
|
1207
|
+
const respell = async (resourceName) => {
|
|
1208
|
+
const part = /^projects\/([^/]+)\//.exec(resourceName)?.[1];
|
|
1209
|
+
if (!part)
|
|
1210
|
+
return resourceName;
|
|
1211
|
+
const isScope = await isScopeProject(part);
|
|
1212
|
+
if (isScope === undefined)
|
|
1213
|
+
return undefined;
|
|
1214
|
+
return isScope ?
|
|
1215
|
+
`projects/${project}/${resourceName.slice(`projects/${part}/`.length)}` :
|
|
1216
|
+
resourceName;
|
|
1217
|
+
};
|
|
1218
|
+
const name = link.name && await respell(link.name);
|
|
1219
|
+
const references = await Promise.all(link.entryReferences.map(async (r) => ({ ...r, name: await respell(r.name) })));
|
|
1220
|
+
if (name === undefined && link.name !== undefined)
|
|
1221
|
+
return undefined;
|
|
1222
|
+
if (references.some(r => r.name === undefined))
|
|
1223
|
+
return undefined;
|
|
1224
|
+
return {
|
|
1225
|
+
...link,
|
|
1226
|
+
name,
|
|
1227
|
+
entryReferences: references,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Deletes `existingLink`, a stale relationship entry link, and recreates it as
|
|
1232
|
+
* `link` with its updated `entryLinkType` and `entryReferences`.
|
|
1233
|
+
*
|
|
1234
|
+
* Once the delete succeeds, the outcome carries `existingLink`, which the
|
|
1235
|
+
* caller uses to add the push-again hint to an error after the delete.
|
|
1236
|
+
*/
|
|
1237
|
+
async function replaceEntryLink(cat, opts, existingLink, link) {
|
|
1238
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
1239
|
+
const del = await cat.deleteEntryLink(opts.project, opts.location, opts.entryGroup, id);
|
|
1240
|
+
if (!isOkStatus(del) && !isNotFoundStatus(del)) {
|
|
1241
|
+
return { error: `entry link '${id}': ${errText(del)}` };
|
|
1242
|
+
}
|
|
1243
|
+
try {
|
|
1244
|
+
const recreated = await cat.createEntryLink(opts.project, opts.location, opts.entryGroup, id, link);
|
|
1245
|
+
return { ...linkOutcome(link, recreated), deleted: existingLink };
|
|
1246
|
+
}
|
|
1247
|
+
catch (err) {
|
|
1248
|
+
const message = String(err?.message || err).trim();
|
|
1249
|
+
return { error: `entry link '${id}': ${message}`, deleted: existingLink };
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* Updates an existing entry link's aspects in place. A link with no aspect,
|
|
1254
|
+
* such as a reference link, has nothing to update.
|
|
1255
|
+
*/
|
|
1256
|
+
async function updateEntryLink(cat, link) {
|
|
1257
|
+
if (!link.aspects)
|
|
1258
|
+
return { status: 200 };
|
|
1259
|
+
return cat.updateEntryLink({ name: link.name, aspects: link.aspects }, Object.keys(link.aspects));
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Returns the error when `createEntryLink` returns 409 because another link
|
|
1263
|
+
* under a different ID already connects the same endpoints. `createError` is
|
|
1264
|
+
* the text of that 409.
|
|
1265
|
+
*/
|
|
1266
|
+
function duplicateEndpointsError(link, createError) {
|
|
1267
|
+
const id = (0, kc_entries_1.linkId)(link);
|
|
1268
|
+
const endpoints = link.entryReferences.map(r => `'${(0, kc_entries_1.entryId)(r)}'`).join(' and ');
|
|
1269
|
+
return `failed to create entry link '${id}' because another '${(0, kc_entries_1.linkTypeId)(link)}' link already connects ${endpoints}; ` +
|
|
1270
|
+
`Knowledge Catalog returned: ${createError}`;
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Writes a single entry to Knowledge Catalog.
|
|
1274
|
+
*
|
|
1275
|
+
* Attempts to create `entry` (retrying brief entry-group propagation 404s), and
|
|
1276
|
+
* if it already exists (HTTP 409), delegates to `updateEntry` with the
|
|
1277
|
+
* entrySource `sources` lists for it. An entry `sources` does not hold is read
|
|
1278
|
+
* first. That happens after a listing the caller may not make, or when another
|
|
1279
|
+
* writer created the entry after the listing.
|
|
1280
|
+
*/
|
|
1281
|
+
async function writeEntry(cat, opts, entry, sources) {
|
|
1282
|
+
const id = (0, kc_entries_1.entryId)(entry);
|
|
1283
|
+
const res = await createEntry(cat, opts, id, entry);
|
|
1284
|
+
if (isExistsStatus(res)) {
|
|
1285
|
+
if (sources.has(id))
|
|
1286
|
+
return updateEntry(cat, opts, entry, sources.get(id));
|
|
1287
|
+
const read = await cat.getEntry(opts.project, opts.location, opts.entryGroup, id, undefined, { keepSourceResource: true });
|
|
1288
|
+
if (!isOkStatus(read)) {
|
|
1289
|
+
return { error: `entry '${id}': reading it before the update: ${errText(read)}` };
|
|
1290
|
+
}
|
|
1291
|
+
return updateEntry(cat, opts, entry, read.result?.entrySource);
|
|
1292
|
+
}
|
|
1293
|
+
if (!isOkStatus(res))
|
|
1294
|
+
return { error: `entry '${id}': ${errText(res)}` };
|
|
1295
|
+
return {};
|
|
1296
|
+
}
|
|
1297
|
+
// The entrySource fields another writer may set, which an update keeps.
|
|
1298
|
+
const KEPT_SOURCE_FIELDS = ['resource', 'system', 'platform', 'createTime', 'updateTime'];
|
|
1299
|
+
/**
|
|
1300
|
+
* Updates an existing entry in place after `createEntry` returns HTTP 409.
|
|
1301
|
+
*
|
|
1302
|
+
* A Dataplex `entries.patch` upserts each aspect `aspectKeys` names that the
|
|
1303
|
+
* body carries, removes one the body leaves out only when
|
|
1304
|
+
* `deleteMissingAspects` is set, and keeps every aspect `aspectKeys` does not
|
|
1305
|
+
* name. Passing `entryAspectKeys(entry, opts)` with `deleteMissingAspects:
|
|
1306
|
+
* true` makes a re-push converge: a still-present optional aspect is refreshed,
|
|
1307
|
+
* and a removed one (such as `guidelines` when `ai_context.instructions` is
|
|
1308
|
+
* deleted, `sql-expressions`, or a field's instance, matched by its `@*` key
|
|
1309
|
+
* under `v2Aspects`) is deleted so a later `pull` does not bring it back. Push
|
|
1310
|
+
* owns the optional aspects `entryAspectKeys` lists, so it removes any that the
|
|
1311
|
+
* model does not declare even if someone added them in the console. Every other
|
|
1312
|
+
* aspect another tool attached is left alone, since `aspectKeys` names only the
|
|
1313
|
+
* types kcmd writes.
|
|
1314
|
+
*
|
|
1315
|
+
* The update mask replaces the whole entrySource, so the update carries over
|
|
1316
|
+
* fields from `source`, the entrySource the entry had before this push. Of
|
|
1317
|
+
* those fields kcmd owns the display name, the description and the
|
|
1318
|
+
* `kcmd-push-id` label. The update keeps another writer's `resource`,
|
|
1319
|
+
* `system`, `platform`, `createTime` and `updateTime`, and every label but
|
|
1320
|
+
* `kcmd-push-id`. It sets that label when `entry` carries it and drops it
|
|
1321
|
+
* when not. Dataplex refuses an update that leaves out an `updateTime` the
|
|
1322
|
+
* entry has, or moves it back. A kept field another writer changes after
|
|
1323
|
+
* `source` was read is written back with the old value. For `updateTime` the
|
|
1324
|
+
* update fails instead.
|
|
1325
|
+
*/
|
|
1326
|
+
async function updateEntry(cat, opts, entry, source) {
|
|
1327
|
+
const { [kc_custom_types_1.PUSH_ID_LABEL]: _own, ...others } = source?.labels ?? {};
|
|
1328
|
+
const labels = { ...others, ...entry.entrySource?.labels };
|
|
1329
|
+
const kept = Object.fromEntries(KEPT_SOURCE_FIELDS.filter(k => source?.[k] !== undefined)
|
|
1330
|
+
.map(k => [k, source[k]]));
|
|
1331
|
+
const merged = {
|
|
1332
|
+
...kept,
|
|
1333
|
+
...entry.entrySource,
|
|
1334
|
+
...(Object.keys(labels).length ? { labels } : {}),
|
|
1335
|
+
};
|
|
1336
|
+
if (Object.keys(merged).length)
|
|
1337
|
+
entry = { ...entry, entrySource: merged };
|
|
1338
|
+
const aspectKeys = (0, kc_entries_1.entryAspectKeys)(entry, opts);
|
|
1339
|
+
const upd = await cat.updateEntry(entry, ['entry_source', 'aspects'], [...aspectKeys],
|
|
1340
|
+
/* deleteMissingAspects= */ true);
|
|
1341
|
+
if (!isOkStatus(upd)) {
|
|
1342
|
+
return { error: `entry '${(0, kc_entries_1.entryId)(entry)}': ${errText(upd)}` };
|
|
1343
|
+
}
|
|
1344
|
+
return { updated: true };
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Creates an entry, retrying brief 404s on a just-created entry group.
|
|
1348
|
+
*/
|
|
1349
|
+
async function createEntry(cat, opts, id, entry) {
|
|
1350
|
+
const tries = opts.entryCreateTries ?? ENTRY_CREATE_TRIES;
|
|
1351
|
+
const retryMs = opts.entryCreateRetryMs ?? ENTRY_CREATE_RETRY_MS;
|
|
1352
|
+
let res = await cat.createEntry(opts.project, opts.location, opts.entryGroup, id, entry);
|
|
1353
|
+
for (let attempt = 1; attempt < tries; attempt++) {
|
|
1354
|
+
if (isOkStatus(res) || isExistsStatus(res) || !isPropagationError(res)) {
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
await sleep(retryMs);
|
|
1358
|
+
res = await cat.createEntry(opts.project, opts.location, opts.entryGroup, id, entry);
|
|
1359
|
+
}
|
|
1360
|
+
return res;
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Returns true when the API response succeeded with HTTP 200 OK.
|
|
1364
|
+
*/
|
|
1365
|
+
function isOkStatus(res) {
|
|
1366
|
+
return res.status === 200;
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Returns true when a create failed because the resource already exists (HTTP
|
|
1370
|
+
* 409 ALREADY_EXISTS), treated as success for idempotent provisioning and
|
|
1371
|
+
* re-push.
|
|
1372
|
+
*/
|
|
1373
|
+
function isExistsStatus(res) {
|
|
1374
|
+
return res.status === 409;
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Returns true when the API response failed because the resource does not exist
|
|
1378
|
+
* (HTTP 404 NOT_FOUND).
|
|
1379
|
+
*/
|
|
1380
|
+
function isNotFoundStatus(res) {
|
|
1381
|
+
return res.status === 404;
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Returns true for a transient "not visible yet" entry-group propagation error
|
|
1385
|
+
* worth retrying, matching the propagation phrasing specifically rather than a
|
|
1386
|
+
* bare "not found" (which also covers a genuinely missing aspect or entry
|
|
1387
|
+
* type).
|
|
1388
|
+
*/
|
|
1389
|
+
function isPropagationError(res) {
|
|
1390
|
+
const msg = res.message ?? '';
|
|
1391
|
+
return /may not exist/i.test(msg) ||
|
|
1392
|
+
/entry group .*(not found|does not exist)/i.test(msg);
|
|
1393
|
+
}
|
|
1394
|
+
function errText(res) {
|
|
1395
|
+
return res.message?.trim() || `HTTP ${res.status}`;
|
|
1396
|
+
}
|