@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,910 @@
1
+ "use strict";
2
+ // OSI (open, AI-first semantics) <-> Semantic Model IR converter.
3
+ //
4
+ // SCAFFOLD (naming for the end state): this file currently holds only the
5
+ // WRITE direction -- IR -> OSI YAML (`serializeModel`). The READ direction
6
+ // (OSI YAML -> IR) still lives in `loader.ts` and moves here once PR4 (the
7
+ // KC push line) merges, at which point this becomes the full two-way
8
+ // converter. Until then, "where is the OSI parser?" -> `loader.ts`.
9
+ //
10
+ // TODO(#278): fold the OSI READ direction in and drop the scaffold. Once
11
+ // #278 merges, move `loadModels` / `fromDocument` / `loadSemanticModels`
12
+ // (and their Load* types) out of `loader.ts` into this file, delete
13
+ // `loader.ts`, and repoint its importers (`src/tool/commands.ts` and the
14
+ // load / validate / bigquery tests). Then this is the sole OSI<->IR codec
15
+ // and the SCAFFOLD note above comes out.
16
+ //
17
+ // Serializing is the inverse of `loader.ts`: `loader` reads authored YAML
18
+ // into the IR; this module writes the IR out as a YAML document the loader
19
+ // can read back. It is the local-workspace sink for `pull` (Knowledge
20
+ // Catalog -> IR -> YAML), the counterpart to how `push` compiles YAML -> IR
21
+ // -> a destination.
22
+ //
23
+ // Fidelity is at the IR level, not byte-for-byte with a hand-authored file. The
24
+ // loader keeps every entry in `dialects` and records `stringForm`, and also
25
+ // picks a target/canonical `expression` + an `importedExpression` via
26
+ // `pickDialect`:
27
+ // * this serializer writes `dialects` back out when present (or short-form
28
+ // bare strings in GOOGLE_VERSION when `stringForm` is true), and falls back
29
+ // to `expression` and `importedExpression` when `dialects` is absent.
30
+ // * comments and key ordering are not preserved.
31
+ // What the loader DOES keep on the IR round-trips here: names, descriptions,
32
+ // `ai_context` (instructions / synonyms / examples / custom properties), keys
33
+ // and unique keys, data sources, field datatypes (omitting `Opaque`) / labels /
34
+ // dimension flags, expressions, relationship join columns, and
35
+ // `custom_extensions`. Output format follows `model.version` via
36
+ // `FORMAT_VERSION_PLACEMENT`: GOOGLE_VERSION ('0.2.0.dev0/google' or unset)
37
+ // writes native extension keys and one `<model>.profile.<name>.yaml` sidecar
38
+ // file per profile, whereas OSSIE_VERSION ('0.2.0.dev0') writes strict Ossie
39
+ // keys, encodes `profiles`, `deployments`, `extends`, relationship
40
+ // `description`, and metric `authoredEntity` in `GOOGLE` `custom_extensions`
41
+ // blocks, and writes no profile files.
42
+ //
43
+ // An `association` (junction-table) relationship has no open-format syntax (the
44
+ // loader cannot produce one), so only its direct foreign-key view (from/to +
45
+ // columns) is serialized; the junction detail is dropped with a note.
46
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
47
+ if (k2 === undefined) k2 = k;
48
+ var desc = Object.getOwnPropertyDescriptor(m, k);
49
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
50
+ desc = { enumerable: true, get: function() { return m[k]; } };
51
+ }
52
+ Object.defineProperty(o, k2, desc);
53
+ }) : (function(o, m, k, k2) {
54
+ if (k2 === undefined) k2 = k;
55
+ o[k2] = m[k];
56
+ }));
57
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
58
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
59
+ }) : function(o, v) {
60
+ o["default"] = v;
61
+ });
62
+ var __importStar = (this && this.__importStar) || (function () {
63
+ var ownKeys = function(o) {
64
+ ownKeys = Object.getOwnPropertyNames || function (o) {
65
+ var ar = [];
66
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
67
+ return ar;
68
+ };
69
+ return ownKeys(o);
70
+ };
71
+ return function (mod) {
72
+ if (mod && mod.__esModule) return mod;
73
+ var result = {};
74
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
75
+ __setModuleDefault(result, mod);
76
+ return result;
77
+ };
78
+ })();
79
+ Object.defineProperty(exports, "__esModule", { value: true });
80
+ exports.serializeModelFiles = serializeModelFiles;
81
+ exports.serializeModel = serializeModel;
82
+ exports.modelDocument = modelDocument;
83
+ exports.compact = compact;
84
+ exports.nonEmpty = nonEmpty;
85
+ const yaml = __importStar(require("yaml"));
86
+ const deployment_target_1 = require("./deployment_target");
87
+ const ir_1 = require("./ir");
88
+ const loader_1 = require("./loader");
89
+ const names_1 = require("./names");
90
+ const resolve_inheritance_1 = require("./resolve_inheritance");
91
+ const resolve_profiles_1 = require("./resolve_profiles");
92
+ // The vendor tag for Google-specific extension blocks on the IR (kept in sync
93
+ // with loader.GOOGLE_VENDOR). In the Google flavor, a model-level deployment
94
+ // target list rides in one as a folded shim alongside `model.deployments`.
95
+ const GOOGLE_VENDOR = 'GOOGLE';
96
+ // The dialect label for the IR's target/canonical `expression` when `dialects`
97
+ // is absent and `stringForm` is not set. BIGQUERY is the loader's default
98
+ // target dialect, so labeling the canonical form BIGQUERY makes the loader
99
+ // re-pick it exactly on reload.
100
+ const CANONICAL_DIALECT = 'BIGQUERY';
101
+ // Only dialect labels the loader accepts are ever written out.
102
+ const ALLOWED_DIALECT_SET = new Set(ir_1.ALLOWED_DIALECTS);
103
+ /**
104
+ * Serializes a semantic model and its sidecar profile files. The returned
105
+ * `files` list always starts with `<model.name>.yaml`. Under `GOOGLE_VERSION`,
106
+ * each entry in `model.profiles` follows as
107
+ * `<model.name>.profile.<profile.name>.yaml`, except that a reserved
108
+ * (`default`), invalid, or case-insensitively repeated name is skipped with a
109
+ * warning. Under `OSSIE_VERSION`, valid profiles are written into the model's
110
+ * `GOOGLE` `custom_extensions` block and no profile files are emitted.
111
+ */
112
+ function serializeModelFiles(model, opts = {}) {
113
+ const warnings = [];
114
+ const version = formatVersionOf(model);
115
+ const document = modelDocument(model, warnings, opts.logical);
116
+ const files = [
117
+ { fileName: `${model.name}.yaml`, content: render(document, opts) },
118
+ ];
119
+ if (loader_1.FORMAT_VERSION_PLACEMENT[version].model.profiles === 'file') {
120
+ for (const { name, doc } of profileDocuments(model, warnings)) {
121
+ files.push({
122
+ fileName: `${model.name}.profile.${name}.yaml`,
123
+ content: render(doc, opts),
124
+ });
125
+ }
126
+ }
127
+ return { files, warnings: [...new Set(warnings)] };
128
+ }
129
+ /**
130
+ * Returns the format version to serialize `model` as (`model.version`,
131
+ * defaulting to `GOOGLE_VERSION` when unset).
132
+ */
133
+ function formatVersionOf(model) {
134
+ return model.version === loader_1.OSSIE_VERSION ? loader_1.OSSIE_VERSION : loader_1.GOOGLE_VERSION;
135
+ }
136
+ /**
137
+ * Validates `model.profiles` and returns the serialized `profileDocument`
138
+ * mapping for each valid profile.
139
+ *
140
+ * Reserved (`default`), invalid, and case-insensitively repeated names are
141
+ * skipped with a warning, keeping the first spelling, so the output satisfies
142
+ * the same name rules the profile-file layout and `readBlockProfiles` enforce
143
+ * on load.
144
+ */
145
+ function profileDocuments(model, warnings) {
146
+ const seen = new Map();
147
+ const out = [];
148
+ for (const profile of model.profiles ?? []) {
149
+ const lower = profile.name.toLowerCase();
150
+ if (lower === resolve_profiles_1.DEFAULT_PROFILE) {
151
+ warnings.push(`model '${model.name}': profile '${profile.name}' uses the name ` +
152
+ `reserved for the model's inline bindings and is not serialized`);
153
+ continue;
154
+ }
155
+ if ((0, names_1.nameProblem)('profile', profile.name)) {
156
+ warnings.push(`model '${model.name}': profile '${profile.name}' is not a valid ` +
157
+ `profile name and is not serialized`);
158
+ continue;
159
+ }
160
+ const first = seen.get(lower);
161
+ if (first !== undefined) {
162
+ // Only a case-only repeat needs the ignoring-case rule explained.
163
+ const repeat = first === profile.name ?
164
+ `profile '${first}' is listed more than once` :
165
+ `profiles '${first}' and '${profile.name}' name the same ` +
166
+ `profile (profile names are unique ignoring case)`;
167
+ warnings.push(`model '${model.name}': ${repeat}; only '${first}' is serialized`);
168
+ continue;
169
+ }
170
+ seen.set(lower, profile.name);
171
+ out.push({
172
+ name: profile.name,
173
+ doc: profileDocument(profile, warnings),
174
+ });
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * Serializes a single semantic model to a YAML document string in the open
180
+ * AI-first semantics format. The document contains exactly this one model;
181
+ * `pull` writes one file per model
182
+ * (catalog/EntryGroups/<entryGroup>/<model>.yaml). It is the first file
183
+ * `serializeModelFiles` returns, with the warnings for every file it builds.
184
+ *
185
+ * Warnings flag IR content that has no loadable representation (an association
186
+ * relationship's junction detail, an imported expression in an unknown dialect,
187
+ * dropped `GOOGLE` blocks under GOOGLE_VERSION, Google-only constructs and
188
+ * missing join columns under OSSIE_VERSION, or loader-invalid gaps), so the
189
+ * caller can surface the lossy edge.
190
+ *
191
+ * `logical` marks the model as a purely logical one (no physical binding).
192
+ * Under GOOGLE_VERSION it suppresses the missing-source and missing-expression
193
+ * warnings, which flag a lossy pull of a bound model. OSSIE_VERSION requires
194
+ * both, so they are still given. An OWL import sets it, and so does a
195
+ * second-generation pull, which loses no binding.
196
+ *
197
+ * `compactFlow` renders leaf collections (scalar sequences, all-scalar maps) in
198
+ * flow style so the output matches the compact convention the semantic-model
199
+ * guides author by hand and is byte-for-byte reproducible there. An OWL import
200
+ * sets it; `pull` does not.
201
+ */
202
+ function serializeModel(model, opts = {}) {
203
+ const { files, warnings } = serializeModelFiles(model, opts);
204
+ return { yaml: files[0].content, warnings };
205
+ }
206
+ // Renders one document as YAML. `aliasDuplicateObjects: false` writes an IR
207
+ // array that two keys share (an entity's key reused as a relationship's
208
+ // `to_columns`, say) out in full at each key, rather than as an `&a1` anchor
209
+ // and `*a1` alias, so editing one binding never silently edits another.
210
+ // `compat: 'yaml-1.1'` quotes strings that a YAML 1.1 parser (PyYAML,
211
+ // ruamel's 1.1 mode) would read as booleans or dates (`'yes'`, `'on'`,
212
+ // `'2001-12-14'`), so arbitrary values in `custom_extensions` or `ai_context`
213
+ // round-trip across tools.
214
+ function render(doc, opts) {
215
+ return opts.compactFlow ? renderCompact(doc) : yaml.stringify(doc, {
216
+ aliasDuplicateObjects: false,
217
+ compat: 'yaml-1.1',
218
+ lineWidth: opts.lineWidth ?? 80,
219
+ });
220
+ }
221
+ // Renders "leaf" collections in flow style so the output matches the compact
222
+ // convention the semantic-model guides author by hand: a sequence whose items
223
+ // are all scalars (`primary_key: [id]`, `from_columns: [fk]`) and a map whose
224
+ // values are all scalars (an inline field `{name, datatype}` or a column-less
225
+ // relationship `{name, from, to}`). Nested structures -- a dataset, an
226
+ // expression's `dialects`, an `ai_context` -- always stay block.
227
+ //
228
+ // A leaf collection is inlined ONLY while it stays short: a map or sequence
229
+ // whose flow form would exceed FLOW_WIDTH_BUDGET (a field carrying a long
230
+ // `description`, say) is left block, so `--compact` never emits an unreadable
231
+ // multi-hundred-character line. `lineWidth: 0` disables the wrapper so an
232
+ // inlined collection is never broken mid-braces -- the width gate, not the
233
+ // wrapper, is what bounds line length. Flow and block parse identically, so
234
+ // this only affects layout.
235
+ const FLOW_WIDTH_BUDGET = 80;
236
+ // The rendered width of a scalar node (its stringified value length); 0 for a
237
+ // non-scalar, which never contributes because a collection holding one is not
238
+ // inlined in the first place.
239
+ function scalarWidth(node) {
240
+ return yaml.isScalar(node) ?
241
+ String(node.value ?? '').length :
242
+ 0;
243
+ }
244
+ function renderCompact(document) {
245
+ const doc = new yaml.Document(document, { aliasDuplicateObjects: false, compat: 'yaml-1.1' });
246
+ yaml.visit(doc, {
247
+ Seq(_, node) {
248
+ if (!node.items.every(item => yaml.isScalar(item)))
249
+ return;
250
+ // `[` + each `item, ` + `]`, approximated for the width gate.
251
+ const width = node.items.reduce((n, item) => n + scalarWidth(item) + 2, 2);
252
+ if (width <= FLOW_WIDTH_BUDGET)
253
+ node.flow = true;
254
+ },
255
+ Map(_, node) {
256
+ // The document's own mapping stays block, as a hand-written file has it.
257
+ if (node === doc.contents)
258
+ return;
259
+ const pairs = node.items;
260
+ if (!pairs.every(pair => yaml.isScalar(pair.value)))
261
+ return;
262
+ // `{` + each `key: value, ` + `}`, approximated for the width gate.
263
+ const width = pairs.reduce((n, pair) => n + scalarWidth(pair.key) + scalarWidth(pair.value) + 4, 2);
264
+ if (width <= FLOW_WIDTH_BUDGET)
265
+ node.flow = true;
266
+ },
267
+ });
268
+ return doc.toString({ flowCollectionPadding: false, lineWidth: 0 });
269
+ }
270
+ // Builds the plain document object (version + one model) that yaml.stringify
271
+ // renders. Kept separate so tests can assert the structure without parsing
272
+ // YAML.
273
+ function modelDocument(model, warnings = [], logical = false) {
274
+ const version = formatVersionOf(model);
275
+ return {
276
+ version,
277
+ semantic_model: [modelDoc(model, warnings, logical, version)],
278
+ };
279
+ }
280
+ // Builds a sidecar profile document matching the schema `loadProfileFile` in
281
+ // `resolve_profiles.ts` reads: a top-level mapping with no `version` or
282
+ // `semantic_model` wrapper. Optional lists (`primary_key`, `unique_keys`,
283
+ // `fields`, `fields_exclude`, `metrics_exclude`, `actions`) are written
284
+ // whenever defined, even when empty, so a ProfileSpec that loadProfileFile read
285
+ // comes back the same from this file.
286
+ function profileDocument(profile, warnings) {
287
+ return compact({
288
+ name: profile.name,
289
+ entities: nonEmpty(profile.entities.map(pe => compact({
290
+ name: pe.name,
291
+ source: pe.source,
292
+ primary_key: pe.primaryKey,
293
+ unique_keys: pe.uniqueKeys,
294
+ fields: pe.fields?.map(pf => {
295
+ const where = `profile '${profile.name}': entity '${pe.name}': field '${pf.name}'`;
296
+ const expression = expressionDoc(pf, /* isGoogleVersion= */ true, where, warnings);
297
+ // loadProfileFile rejects an entry with nothing to bind, and
298
+ // (like a model load) any blank SQL text.
299
+ const texts = typeof expression === 'string' ?
300
+ [expression] :
301
+ (expression?.dialects ?? []).map(d => d.expression);
302
+ if (expression === undefined) {
303
+ warnings.push(`${where}: no expression; the profile file will not load ` +
304
+ `until it is set or the field is listed in ` +
305
+ `'fields_exclude'.`);
306
+ }
307
+ else if (texts.some(t => t.trim() === '')) {
308
+ warnings.push(`${where}: expression is blank; the profile file will not ` +
309
+ `load until its SQL is written.`);
310
+ }
311
+ return compact({ name: pf.name, expression });
312
+ }),
313
+ fields_exclude: pe.fieldsExclude,
314
+ }))),
315
+ relationships: nonEmpty(profile.relationships.map(pr => compact({
316
+ name: pr.name,
317
+ from_columns: nonEmpty(pr.fromColumns),
318
+ to_columns: nonEmpty(pr.toColumns),
319
+ }))),
320
+ metrics_exclude: profile.metricsExclude,
321
+ actions: profile.actions?.map(pa => compact({
322
+ name: pa.name,
323
+ executor: pa.executor === null ?
324
+ null :
325
+ (pa.executor ? executorDoc(pa.executor) : undefined),
326
+ })),
327
+ });
328
+ }
329
+ function modelDoc(model, warnings, logical, version) {
330
+ const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
331
+ const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
332
+ // `datasets` is required (min 1) by the loader. A reconstructed model with no
333
+ // entities (e.g. every entity fetch failed during a pull) would serialize to
334
+ // a document the loader rejects; emit the (empty) array but flag it so the
335
+ // lossy edge is visible rather than surfacing later as an opaque load error.
336
+ const datasets = (model.entities ??
337
+ []).map(e => datasetDoc(e, warnings, logical, version));
338
+ if (!datasets.length) {
339
+ warnings.push(`model '${model.name}': no datasets (entities); the document requires ` +
340
+ `at least one and will not load until an entity is present.`);
341
+ }
342
+ const tableKey = isGoogleVersion ? 'entities' : 'datasets';
343
+ // Under GOOGLE_VERSION, deployments come from `model.deployments`, or from a
344
+ // GOOGLE `deploymentTargets` block when that is empty. Under OSSIE_VERSION,
345
+ // named deployments and profiles go in a GOOGLE block on the model.
346
+ const namedDeployments = (model.deployments ?? []).map(d => compact({
347
+ name: d.name,
348
+ target: d.target,
349
+ profile: d.profile
350
+ }));
351
+ const deployments = placement.model.deployments === 'native' ?
352
+ nonEmpty(namedDeployments.length ? namedDeployments :
353
+ deploymentsFromGoogleExtensions(model)) :
354
+ undefined;
355
+ const blockProfiles = placement.model.profiles === 'block' ?
356
+ nonEmpty(profileDocuments(model, warnings).map(p => p.doc)) :
357
+ undefined;
358
+ const blockDeployments = placement.model.deployments === 'block' ?
359
+ nonEmpty(namedDeployments) :
360
+ undefined;
361
+ const blockPayload = compact({
362
+ profiles: blockProfiles,
363
+ deployments: blockDeployments,
364
+ });
365
+ if (!isGoogleVersion)
366
+ warnGoogleOnlyContent(model, warnings);
367
+ const emittedFields = placement.model.actions === 'native' && model.actions?.length ?
368
+ emitted(model) :
369
+ null;
370
+ return compact({
371
+ name: model.name,
372
+ description: model.description,
373
+ ai_context: aiContextDoc(model.aiContext, isGoogleVersion),
374
+ deployments,
375
+ [tableKey]: datasets,
376
+ relationships: nonEmpty((model.relationships ??
377
+ []).map(r => relationshipDoc(r, warnings, version))),
378
+ metrics: nonEmpty((model.metrics ?? []).map(m => metricDoc(m, warnings, version))),
379
+ // Hoisted out of the map: `emitted` resolves inheritance, which deep-clones
380
+ // the model, and the answer does not vary by action.
381
+ actions: placement.model.actions === 'native' ?
382
+ nonEmpty((model.actions ??
383
+ []).map(a => actionDoc(a, warnings, emittedFields))) :
384
+ undefined,
385
+ constraints: placement.model.constraints === 'native' ?
386
+ nonEmpty((model.constraints ?? []).map(c => constraintDoc(c))) :
387
+ undefined,
388
+ custom_extensions: customExtensionsDoc(model.customExtensions, `model '${model.name}'`, isGoogleVersion,
389
+ /* isModel= */ true, warnings, /* isField= */ false, blockPayload),
390
+ });
391
+ }
392
+ // Warns about each IR construct OSSIE_VERSION has no representation for,
393
+ // natively or in a GOOGLE block, so a '0.2.0.dev0' write flags dropped
394
+ // content just as a GOOGLE_VERSION write flags a dropped GOOGLE block.
395
+ function warnGoogleOnlyContent(model, warnings) {
396
+ const placement = loader_1.FORMAT_VERSION_PLACEMENT[loader_1.OSSIE_VERSION];
397
+ const drop = (where, what) => warnings.push(`${where}: ${what} has no representation under '${loader_1.OSSIE_VERSION}' and ` +
398
+ `is not serialized`);
399
+ const where = `model '${model.name}'`;
400
+ if (placement.model.actions === 'none' && model.actions?.length) {
401
+ drop(where, `'actions'`);
402
+ }
403
+ if (placement.model.constraints === 'none' && model.constraints?.length) {
404
+ drop(where, `'constraints'`);
405
+ }
406
+ for (const e of model.entities ?? []) {
407
+ if (placement.dataset.abstract === 'none' && e.abstract) {
408
+ drop(`entity '${e.name}'`, `'abstract'`);
409
+ }
410
+ }
411
+ }
412
+ function datasetDoc(entity, warnings, logical, version) {
413
+ const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
414
+ const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
415
+ // A concrete (non-abstract) entity with no source cannot be reloaded -- the
416
+ // loader requires `source` unless the dataset is abstract -- so surface the
417
+ // gap at write time instead of emitting a document that fails to load. Under
418
+ // OSSIE_VERSION `abstract` is omitted, so a dataset without `dataSource`
419
+ // cannot reload even if `entity.abstract` was set. Suppressed for a logical
420
+ // model under GOOGLE_VERSION, where a missing source is intended.
421
+ // OSSIE_VERSION requires a source on every dataset.
422
+ if ((!logical || !isGoogleVersion) && !(isGoogleVersion && entity.abstract) &&
423
+ !entity.dataSource) {
424
+ warnings.push(`entity '${entity.name}' has no source${isGoogleVersion ? ' and is not abstract' : ''}; the ` +
425
+ `serialized document will not reload until a source is set`);
426
+ }
427
+ const parents = nonEmpty(entity.extends);
428
+ const nativeExtends = placement.dataset.extends === 'native' ? parents : undefined;
429
+ const nativeAbstract = placement.dataset.abstract === 'native' ?
430
+ (entity.abstract || undefined) :
431
+ undefined;
432
+ const blockPayload = placement.dataset.extends === 'block' ?
433
+ compact({ extends: parents }) :
434
+ undefined;
435
+ return compact({
436
+ name: entity.name,
437
+ // An abstract entity has no physical table, so its source is empty; omit
438
+ // the key rather than emit `source: ""` (which the loader rejects). The
439
+ // source as written survives serialization.
440
+ source: (0, loader_1.writtenSource)(entity) || undefined,
441
+ // Supertype entities (entity-level inheritance); native under
442
+ // GOOGLE_VERSION, or in the dataset's GOOGLE block under OSSIE_VERSION.
443
+ extends: nativeExtends,
444
+ // Conceptual (table-less) marker; GOOGLE_VERSION only.
445
+ abstract: nativeAbstract,
446
+ primary_key: nonEmpty(entity.keys),
447
+ unique_keys: nonEmpty(entity.uniqueKeys),
448
+ description: entity.description,
449
+ ai_context: aiContextDoc(entity.aiContext, isGoogleVersion),
450
+ fields: nonEmpty((entity.fields ?? [])
451
+ .map(f => fieldDoc(f, warnings, logical || (isGoogleVersion && !!entity.abstract), isGoogleVersion))),
452
+ custom_extensions: customExtensionsDoc(entity.customExtensions, `entity '${entity.name}'`, isGoogleVersion,
453
+ /* isModel= */ false, warnings, /* isField= */ false, blockPayload),
454
+ });
455
+ }
456
+ // Filters out the IR's `'Opaque'` placeholder so untyped fields and metrics
457
+ // omit the `datatype` key in YAML. `'Opaque'` marks an unknown or unmapped type
458
+ // (e.g. from `kcmd pull` or OWL import); action parameter helpers also use this
459
+ // to tell whether a projected field will emit a concrete datatype on reload.
460
+ function datatypeDoc(type) {
461
+ return type === undefined || type === 'Opaque' ? undefined :
462
+ type;
463
+ }
464
+ function fieldDoc(field, warnings, logical, isGoogleVersion) {
465
+ const expression = expressionDoc(field, isGoogleVersion, `field '${field.name}'`, warnings);
466
+ // Under GOOGLE_VERSION a field with no expression reloads unbound. A logical
467
+ // field, or one on an abstract entity, has none on purpose and is not warned.
468
+ // OSSIE_VERSION requires an expression on every field.
469
+ if ((!logical || !isGoogleVersion) && !expression) {
470
+ warnings.push(isGoogleVersion ?
471
+ `field '${field.name}': no expression; it reloads as an unbound ` +
472
+ `field.` :
473
+ `field '${field.name}': no expression; a '${loader_1.OSSIE_VERSION}' ` +
474
+ `document requires one per field and will not load until it ` +
475
+ `is set.`);
476
+ }
477
+ return compact({
478
+ name: field.name,
479
+ expression,
480
+ datatype: datatypeDoc(field.type),
481
+ label: field.label,
482
+ dimension: dimensionDoc(field),
483
+ description: field.description,
484
+ ai_context: aiContextDoc(field.aiContext, isGoogleVersion),
485
+ custom_extensions: customExtensionsDoc(field.customExtensions, `field '${field.name}'`, isGoogleVersion,
486
+ /* isModel= */ false, warnings, /* isField= */ true),
487
+ });
488
+ }
489
+ function metricDoc(metric, warnings, version) {
490
+ const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
491
+ const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
492
+ // Emit `entity` only from `authoredEntity` (natively under GOOGLE_VERSION or
493
+ // in the metric's GOOGLE block under OSSIE_VERSION). A derived
494
+ // `metric.entity` is recomputed by the loader from SQL qualifiers.
495
+ const expression = expressionDoc(metric, isGoogleVersion, `metric '${metric.name}'`, warnings);
496
+ if (!expression) {
497
+ warnings.push(`metric '${metric.name}': no expression; the loader requires one per ` +
498
+ `metric and the document will not load until it is set.`);
499
+ }
500
+ const authoredEntity = metric.authoredEntity || undefined;
501
+ const blockPayload = placement.metric.entity === 'block' ?
502
+ compact({ entity: authoredEntity }) :
503
+ undefined;
504
+ return compact({
505
+ name: metric.name,
506
+ entity: placement.metric.entity === 'native' ? authoredEntity : undefined,
507
+ expression,
508
+ datatype: datatypeDoc(metric.type),
509
+ description: metric.description,
510
+ ai_context: aiContextDoc(metric.aiContext, isGoogleVersion),
511
+ custom_extensions: customExtensionsDoc(metric.customExtensions, `metric '${metric.name}'`, isGoogleVersion,
512
+ /* isModel= */ false, warnings, /* isField= */ false, blockPayload),
513
+ });
514
+ }
515
+ // The `Concept.field` pairs this document will still resolve on reload. A
516
+ // projection is only worth emitting as a projection if the field it names
517
+ // comes out in the same document -- see `parameterDoc`. It asks the loader's
518
+ // own resolver rather than walking the model here, so the emitter cannot come
519
+ // to a different answer than the load it is writing for. A resolver that
520
+ // throws leaves every projection emitted as authored: the emitter is not the
521
+ // place to discover that a model does not load.
522
+ function emitted(model) {
523
+ let concepts;
524
+ try {
525
+ concepts = (0, resolve_inheritance_1.declaredConceptFields)(model);
526
+ }
527
+ catch {
528
+ return null;
529
+ }
530
+ const pairs = new Set();
531
+ for (const [name, concept] of concepts) {
532
+ // A field carrying no emitted datatype resolves to none on reload, so
533
+ // emitting a projection at it would suppress the parameter's resolved
534
+ // `type` and hand back an untyped parameter the loader rejects. Treating
535
+ // the pair as unresolvable falls the parameter back to that resolved type,
536
+ // which is the whole point of the fallback, and warns when there is none.
537
+ for (const [field, def] of concept.fields) {
538
+ if (datatypeDoc(def.type) !== undefined)
539
+ pairs.add(`${name}.${field}`);
540
+ }
541
+ }
542
+ return pairs;
543
+ }
544
+ // Inverts loader.convertAction. The executor collapses back to the open
545
+ // format's single-key object; parameters emit as their authoring form. Actions
546
+ // only exist under GOOGLE_VERSION, and loader.ts rejects custom_extensions on
547
+ // them.
548
+ function actionDoc(action, warnings, resolvable) {
549
+ if (action.customExtensions?.length) {
550
+ warnings.push(`action '${action.name}': ${action.customExtensions
551
+ .length} custom_extension(s) have no representation ` +
552
+ `under '${loader_1.GOOGLE_VERSION}' and are not serialized`);
553
+ }
554
+ return compact({
555
+ name: action.name,
556
+ description: action.description,
557
+ executor: action.executor ? executorDoc(action.executor) : undefined,
558
+ parameters: nonEmpty((action.parameters ??
559
+ []).map(p => parameterDoc(p, resolvable, action.name, warnings))),
560
+ guards: nonEmpty(action.guards),
561
+ affects: nonEmpty((action.affects ?? []).map(affectedConceptDoc)),
562
+ ai_context: aiContextDoc(action.aiContext, /* isGoogleVersion= */ true),
563
+ });
564
+ }
565
+ // One action parameter, back in the form an author writes.
566
+ //
567
+ // A parameter projected from a field emits the projection and NOT the type.
568
+ // The type is the field's, copied in at load; writing it back out would make
569
+ // the document state a type alongside `concept`/`field`, which is the one
570
+ // thing the loader refuses outright -- so emitting it would produce a document
571
+ // this tool cannot read. The reload resolves it again from the same field and
572
+ // arrives at the same answer.
573
+ //
574
+ // Wording is emitted whether the author wrote it or inherited it, because
575
+ // nothing here can tell those apart and the two reload identically: an
576
+ // authored value that matches the field's is the field's. So this is verbose
577
+ // where the author was terse, and exact either way.
578
+ //
579
+ // `name` is likewise always written, even where the author let it default to
580
+ // the field's name. Same trade, same reason.
581
+ // A projection emits as a projection only where the field it names comes out
582
+ // in this same document. Pruning can take the concept away -- an entity no
583
+ // binding reaches is dropped, and the parameter that projected from it is
584
+ // deliberately kept, because it copied what it needed at load time. Emitting
585
+ // `concept`/`field` anyway would write a document that no longer loads: the
586
+ // pair resolves to nothing, and the type that would have rescued it was
587
+ // suppressed precisely because the projection was supposed to supply it. So
588
+ // where the field is gone, the parameter emits as the declared one it has
589
+ // effectively become -- its resolved `type`, and none of the projection.
590
+ function parameterDoc(p, resolvable, action, warnings) {
591
+ const projects = p.concept !== undefined &&
592
+ (resolvable === null || resolvable.has(`${p.concept}.${p.field}`));
593
+ // The fallback above rests on there BEING a resolved type to fall back to,
594
+ // and a parameter can reach here without one -- a pull of an aspect written
595
+ // before the type was recorded, or a model whose inheritance would not
596
+ // resolve. Both keys then drop and the parameter emits as name and wording
597
+ // alone, which reloads as a typeless parameter and is rejected by validate.
598
+ // Nothing here can invent the type, so say so at write time rather than let
599
+ // it surface as a load error against a document this wrote. `Opaque` is a
600
+ // valid standalone parameter type in the loader (unlike on fields and
601
+ // metrics), so `p.type` is emitted directly without `datatypeDoc`.
602
+ if (!projects && p.concept !== undefined && p.type === undefined) {
603
+ warnings.push(`action '${action}': parameter '${p.name}' projected from '${p.concept}.${p.field}', which this document does not carry, and ` +
604
+ `no resolved datatype was recorded to write in its place. The ` +
605
+ `parameter emits with no type and the document will not load until ` +
606
+ `one is supplied.`);
607
+ }
608
+ return compact({
609
+ name: p.name,
610
+ type: projects ? undefined : p.type,
611
+ concept: projects ? p.concept : undefined,
612
+ field: projects ? p.field : undefined,
613
+ label: p.label,
614
+ description: p.description,
615
+ ai_context: aiContextDoc(p.aiContext, /* isGoogleVersion= */ true),
616
+ required: p.required,
617
+ default: p.default,
618
+ });
619
+ }
620
+ // One affected concept back to the open format. An entry that says nothing
621
+ // beyond which
622
+ // concept it touches emits as the bare name, which is the shorthand the author
623
+ // most likely wrote and the form the proposal uses; anything with an operation
624
+ // or fields needs the record.
625
+ //
626
+ // This NORMALIZES rather than reproducing the document byte for byte: an
627
+ // authored `{concept: Order}` with no operation comes back as `Order`. The two
628
+ // mean the same thing, so the emit is a fixed point after one pass, which is
629
+ // what the round-trip tests assert.
630
+ function affectedConceptDoc(affected) {
631
+ if (!affected.operation && !affected.fields?.length)
632
+ return affected.concept;
633
+ return compact({
634
+ concept: affected.concept,
635
+ operation: affected.operation,
636
+ fields: nonEmpty(affected.fields),
637
+ });
638
+ }
639
+ // Inverts loader.convertConstraint. The judgment is a logical invariant and
640
+ // round-trips verbatim, since nothing about it is derived. The aspect type's
641
+ // reserved `expression` and `evaluation` fields have no counterpart here: one
642
+ // names a body the model no longer has, and the other was computed from which
643
+ // body a constraint stated, so emitting either would put something the author
644
+ // cannot write into an authored document.
645
+ function constraintDoc(constraint) {
646
+ // No custom_extensions call: a constraint carries no custom extensions.
647
+ return compact({
648
+ name: constraint.name,
649
+ judgment: constraint.judgment,
650
+ description: constraint.description,
651
+ on_violation: constraint.onViolation,
652
+ severity: constraint.severity,
653
+ ai_context: aiContextDoc(constraint.aiContext, /* isGoogleVersion= */ true),
654
+ });
655
+ }
656
+ // Collapses the IR's tagged executor union back to the open format's single-key
657
+ // object ({mcp: {...}} / {rest: {...}} / {grpc: {...}}).
658
+ function executorDoc(ex) {
659
+ switch (ex.kind) {
660
+ case 'mcp':
661
+ return { mcp: { server: ex.mcp.server, tool: ex.mcp.tool } };
662
+ case 'rest':
663
+ return { rest: { endpoint: ex.rest.endpoint, method: ex.rest.method } };
664
+ case 'grpc':
665
+ return { grpc: { service: ex.grpc.service, method: ex.grpc.method } };
666
+ case 'sql':
667
+ return { sql: { statements: [...ex.sql.statements] } };
668
+ }
669
+ }
670
+ // Inverts loader.convertRelationship: `from`/`to` are the endpoint entities and
671
+ // `from_columns`/`to_columns` are their positional join columns. An association
672
+ // (junction-table) edge has no open-format syntax, so only this direct-FK view
673
+ // is emitted and the junction detail is flagged.
674
+ function relationshipDoc(rel, warnings, version) {
675
+ const isGoogleVersion = version === loader_1.GOOGLE_VERSION;
676
+ const placement = loader_1.FORMAT_VERSION_PLACEMENT[version];
677
+ if (rel.association) {
678
+ warnings.push(`relationship '${rel.name}': association (junction-table) detail has no ` +
679
+ `open-format representation and is not serialized; only its ` +
680
+ `foreign-key endpoints are written.`);
681
+ }
682
+ if (!isGoogleVersion &&
683
+ (!rel.source.columns.length || !rel.destination.columns.length)) {
684
+ warnings.push(`relationship '${rel.name}': no join columns; a '${loader_1.OSSIE_VERSION}' document requires from_columns and to_columns ` +
685
+ `and will not load until they are set.`);
686
+ }
687
+ const description = rel.description || undefined;
688
+ const blockPayload = placement.relationship.description === 'block' ?
689
+ compact({ description }) :
690
+ undefined;
691
+ return compact({
692
+ name: rel.name,
693
+ from: rel.source.entity,
694
+ to: rel.destination.entity,
695
+ from_columns: nonEmpty(rel.source.columns),
696
+ to_columns: nonEmpty(rel.destination.columns),
697
+ description: placement.relationship.description === 'native' ? description :
698
+ undefined,
699
+ ai_context: aiContextDoc(rel.aiContext, isGoogleVersion),
700
+ custom_extensions: customExtensionsDoc(rel.customExtensions, `relationship '${rel.name}'`, isGoogleVersion,
701
+ /* isModel= */ false, warnings, /* isField= */ false, blockPayload),
702
+ });
703
+ }
704
+ // Renders a field or metric `expression` for the target version. The IR comes
705
+ // from two sources:
706
+ // 1. YAML loaded by `loader.ts`, which populates `dialects` and `stringForm`.
707
+ // 2. Models built by `kcmd pull` or OWL import, which leave `dialects` unset
708
+ // and only populate `expression` (plus optional `importedExpression`).
709
+ function expressionDoc(item, isGoogleVersion, where, warnings) {
710
+ // Path 1 (YAML round-trip): re-emit the original `dialects` list in authored
711
+ // order. Under GOOGLE_VERSION, `stringForm` means the author wrote a bare
712
+ // string shorthand (`expression: "SUM(amt)"`), so emit the bare string back;
713
+ // under OSSIE_VERSION bare strings are forbidden, so emit `{dialects:
714
+ // [...]}`.
715
+ if (item.dialects?.length) {
716
+ const allowed = [];
717
+ for (const d of item.dialects) {
718
+ if (ALLOWED_DIALECT_SET.has(d.dialect)) {
719
+ allowed.push(d);
720
+ }
721
+ else {
722
+ warnings.push(`${where}: expression in dialect '${d.dialect}' is not a dialect ` +
723
+ `the loader accepts and is not serialized`);
724
+ }
725
+ }
726
+ if (allowed.length) {
727
+ if (isGoogleVersion && item.stringForm) {
728
+ return allowed[0].expression;
729
+ }
730
+ return {
731
+ dialects: allowed.map(d => ({ dialect: d.dialect, expression: d.expression })),
732
+ };
733
+ }
734
+ }
735
+ // Path 2 (`kcmd pull` / OWL import / fallback): `dialects` is absent, so
736
+ // synthesize it from `expression` and `importedExpression`.
737
+ if (isGoogleVersion && item.stringForm && item.expression !== undefined &&
738
+ item.importedExpression === undefined) {
739
+ return item.expression;
740
+ }
741
+ const dialects = [];
742
+ if (item.expression !== undefined) {
743
+ // `loader.ts` wraps a bare-string expression as ANSI_SQL; otherwise label
744
+ // the canonical expression BIGQUERY so reload picks it back up.
745
+ dialects.push({
746
+ dialect: item.stringForm ? 'ANSI_SQL' : CANONICAL_DIALECT,
747
+ expression: item.expression,
748
+ });
749
+ }
750
+ if (item.importedExpression !== undefined) {
751
+ // `importedExpression` holds third-party vendor SQL, such as SNOWFLAKE or
752
+ // DATABRICKS, kept for transpilation. Only emit `importedDialect` when the
753
+ // loader accepts it; an unknown or missing label would fail to reload.
754
+ const label = item.importedDialect;
755
+ if (label === undefined) {
756
+ warnings.push(`${where}: imported expression has no dialect and is not serialized`);
757
+ }
758
+ else if (!ALLOWED_DIALECT_SET.has(label)) {
759
+ warnings.push(`${where}: imported expression in dialect '${label}' is not a ` +
760
+ `dialect the loader accepts and is not serialized`);
761
+ }
762
+ else if (dialects.some(d => d.dialect === label)) {
763
+ warnings.push(`${where}: imported expression in dialect '${label}' has the same ` +
764
+ `dialect as the expression and is not serialized`);
765
+ }
766
+ else {
767
+ dialects.push({ dialect: label, expression: item.importedExpression });
768
+ }
769
+ }
770
+ return dialects.length ? { dialects } : undefined;
771
+ }
772
+ // Emits the field's `dimension` block when present, inverting
773
+ // loader.convertField (which sets `dimension = {}` for a bare marker and copies
774
+ // `is_time`). The key is emitted whenever the IR carries dimension metadata,
775
+ // even for an empty marker, so a dimension field reloads as a dimension.
776
+ function dimensionDoc(field) {
777
+ if (!field.dimension)
778
+ return undefined;
779
+ return compact({ is_time: field.dimension.isTime });
780
+ }
781
+ // Emits the structured `ai_context` for the target version, inverting
782
+ // loader.normalizeAiContext. Custom properties (`additionalProperties`) sit
783
+ // under `custom:` under GOOGLE_VERSION and as sibling keys under OSSIE_VERSION.
784
+ function aiContextDoc(ai, isGoogleVersion) {
785
+ if (!ai)
786
+ return undefined;
787
+ const custom = ai.additionalProperties ? compact(ai.additionalProperties) : undefined;
788
+ const hasCustom = custom !== undefined && Object.keys(custom).length > 0;
789
+ const base = compact({
790
+ instructions: ai.instructions,
791
+ synonyms: nonEmpty(ai.synonyms),
792
+ examples: nonEmpty(ai.examples),
793
+ });
794
+ if (!hasCustom) {
795
+ return Object.keys(base).length ? base : undefined;
796
+ }
797
+ return isGoogleVersion ? { ...base, custom } : { ...custom, ...base };
798
+ }
799
+ // The deployments a vanilla model lists only as targets in its GOOGLE blocks,
800
+ // named as a pull names them. A block whose data is not valid JSON gives none,
801
+ // and customExtensionsDoc warns that it was dropped. The other blocks still
802
+ // give theirs.
803
+ function deploymentsFromGoogleExtensions(model) {
804
+ const targets = (model.customExtensions ??
805
+ []).flatMap(ext => blockTargets(ext, model.name));
806
+ return (0, deployment_target_1.unnamedDeployments)(targets);
807
+ }
808
+ // Serializes `custom_extensions` for one IR element. Under OSSIE_VERSION,
809
+ // third-party extensions are emitted, followed by a synthesized GOOGLE block
810
+ // when `blockPayload` carries any keys. Any other raw GOOGLE block on the IR
811
+ // is dropped with a warning so it cannot collide with the synthesized block or
812
+ // write keys the loader rejects. Under GOOGLE_VERSION,
813
+ // third-party extensions are written out unchanged without warning, while
814
+ // `GOOGLE` blocks are skipped (silently for the model's `deploymentTargets`
815
+ // block, and with a warning for any other `GOOGLE` block).
816
+ function customExtensionsDoc(exts, where, isGoogleVersion, isModel, warnings, isField = false, blockPayload) {
817
+ const payload = blockPayload ? { ...blockPayload } : {};
818
+ if (!exts?.length && Object.keys(payload).length === 0)
819
+ return undefined;
820
+ if (!isGoogleVersion) {
821
+ const kept = [];
822
+ let droppedGoogle = 0;
823
+ for (const e of exts ?? []) {
824
+ if (e.vendorName !== GOOGLE_VENDOR) {
825
+ kept.push({ vendor_name: e.vendorName, data: e.data });
826
+ continue;
827
+ }
828
+ // The loader's copy of the model's `deployments`, which the GOOGLE
829
+ // block below writes in their own form.
830
+ if (isModel && payload.deployments !== undefined &&
831
+ isDeploymentTargetsBlock(e)) {
832
+ continue;
833
+ }
834
+ droppedGoogle += 1;
835
+ }
836
+ if (droppedGoogle > 0) {
837
+ warnings.push(isField ?
838
+ `${where}: ${droppedGoogle} GOOGLE custom_extension(s) have no ` +
839
+ `representation on a field under '${loader_1.OSSIE_VERSION}' and ` +
840
+ `are not serialized` :
841
+ `${where}: ${droppedGoogle} GOOGLE custom_extension(s) are not ` +
842
+ `serialized under '${loader_1.OSSIE_VERSION}'; its GOOGLE block is ` +
843
+ `written from its own fields`);
844
+ }
845
+ if (Object.keys(payload).length > 0) {
846
+ kept.push({
847
+ vendor_name: GOOGLE_VENDOR,
848
+ data: JSON.stringify(payload),
849
+ });
850
+ }
851
+ return nonEmpty(kept);
852
+ }
853
+ // No GOOGLE_VERSION construct has 'block' placement, so `blockPayload` is
854
+ // empty here.
855
+ const kept = [];
856
+ let droppedGoogle = 0;
857
+ for (const ext of exts ?? []) {
858
+ if (ext.vendorName !== GOOGLE_VENDOR) {
859
+ kept.push({ vendor_name: ext.vendorName, data: ext.data });
860
+ continue;
861
+ }
862
+ if (isModel && blockTargets(ext, '').length > 0)
863
+ continue;
864
+ droppedGoogle += 1;
865
+ }
866
+ if (droppedGoogle > 0) {
867
+ warnings.push(`${where}: ${droppedGoogle} GOOGLE custom_extension(s) have no ` +
868
+ `representation under '${loader_1.GOOGLE_VERSION}' and are not serialized`);
869
+ }
870
+ return nonEmpty(kept);
871
+ }
872
+ /**
873
+ * Returns true if `e.data` is a JSON object with a `'deploymentTargets'`
874
+ * property.
875
+ *
876
+ * The loader folds a Google-flavor model's `deployments` into such a block.
877
+ */
878
+ function isDeploymentTargetsBlock(e) {
879
+ try {
880
+ const d = JSON.parse(e.data);
881
+ return !!d && typeof d === 'object' &&
882
+ Object.hasOwn(d, 'deploymentTargets');
883
+ }
884
+ catch {
885
+ return false;
886
+ }
887
+ }
888
+ function blockTargets(ext, modelName) {
889
+ try {
890
+ return (0, deployment_target_1.googleBlockTargets)([ext], modelName);
891
+ }
892
+ catch {
893
+ return [];
894
+ }
895
+ }
896
+ // Drops undefined-valued keys so the emitted YAML only shows fields the model
897
+ // actually set (matching the emitters' `compact` convention).
898
+ function compact(obj) {
899
+ const out = {};
900
+ for (const [k, v] of Object.entries(obj)) {
901
+ if (v !== undefined)
902
+ out[k] = v;
903
+ }
904
+ return out;
905
+ }
906
+ // Returns the array, or undefined when empty/absent, so an empty list is
907
+ // omitted rather than rendered as `[]`.
908
+ function nonEmpty(items) {
909
+ return items && items.length ? items : undefined;
910
+ }