@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.
Files changed (150) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +266 -2
  3. package/build/ts/kcmd/cli.js +19578 -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 +16 -0
  9. package/build/ts/kcmd/gcp/biglake.js +50 -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 +63 -0
  21. package/build/ts/kcmd/gcp/spanner.js +123 -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 +344 -0
  30. package/build/ts/kcmd/layouts/semantic-model.d.ts +61 -0
  31. package/build/ts/kcmd/layouts/semantic-model.js +542 -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 +104 -0
  42. package/build/ts/kcmd/okf/concept_to_entry.d.ts +8 -0
  43. package/build/ts/kcmd/okf/concept_to_entry.js +254 -0
  44. package/build/ts/kcmd/okf/init_okf_workspace.d.ts +15 -0
  45. package/build/ts/kcmd/okf/init_okf_workspace.js +115 -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 +82 -0
  50. package/build/ts/kcmd/semantic/bigquery.d.ts +22 -0
  51. package/build/ts/kcmd/semantic/bigquery.js +525 -0
  52. package/build/ts/kcmd/semantic/binding.d.ts +3 -0
  53. package/build/ts/kcmd/semantic/binding.js +97 -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 +129 -0
  67. package/build/ts/kcmd/semantic/deploy_knowledge_catalog.js +1396 -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 +34 -0
  73. package/build/ts/kcmd/semantic/graph_ddl.js +563 -0
  74. package/build/ts/kcmd/semantic/import_model.d.ts +50 -0
  75. package/build/ts/kcmd/semantic/import_model.js +1275 -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 +31 -0
  83. package/build/ts/kcmd/semantic/kc_converter.js +824 -0
  84. package/build/ts/kcmd/semantic/kc_custom_types.d.ts +70 -0
  85. package/build/ts/kcmd/semantic/kc_custom_types.js +828 -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 +103 -0
  89. package/build/ts/kcmd/semantic/kc_ids.js +191 -0
  90. package/build/ts/kcmd/semantic/knowledge_catalog.d.ts +32 -0
  91. package/build/ts/kcmd/semantic/knowledge_catalog.js +1043 -0
  92. package/build/ts/kcmd/semantic/loader.d.ts +262 -0
  93. package/build/ts/kcmd/semantic/loader.js +3440 -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 +911 -0
  98. package/build/ts/kcmd/semantic/parameters.d.ts +18 -0
  99. package/build/ts/kcmd/semantic/parameters.js +152 -0
  100. package/build/ts/kcmd/semantic/pull_kc.d.ts +14 -0
  101. package/build/ts/kcmd/semantic/pull_kc.js +346 -0
  102. package/build/ts/kcmd/semantic/resolve_inheritance.d.ts +54 -0
  103. package/build/ts/kcmd/semantic/resolve_inheritance.js +330 -0
  104. package/build/ts/kcmd/semantic/resolve_profiles.d.ts +250 -0
  105. package/build/ts/kcmd/semantic/resolve_profiles.js +1822 -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 +94 -0
  111. package/build/ts/kcmd/semantic/runtime/runtime.js +318 -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 +10 -0
  117. package/build/ts/kcmd/semantic/source_dialect.js +261 -0
  118. package/build/ts/kcmd/semantic/source_entries.d.ts +12 -0
  119. package/build/ts/kcmd/semantic/source_entries.js +282 -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 +17 -0
  123. package/build/ts/kcmd/semantic/sql_expr_utils.js +221 -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 +44 -0
  127. package/build/ts/kcmd/semantic/sql_parser.js +378 -0
  128. package/build/ts/kcmd/semantic/transpile.d.ts +74 -0
  129. package/build/ts/kcmd/semantic/transpile.js +563 -0
  130. package/build/ts/kcmd/semantic/validate.d.ts +35 -0
  131. package/build/ts/kcmd/semantic/validate.js +1899 -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 +33 -0
  137. package/build/ts/kcmd/snapshot.js +379 -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 +697 -0
  150. 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
+ }