@google-cloud/kcmd 0.0.0-stage → 0.2.0

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