|
| 1 | +import { BaseServiceSelfHostedConfig, ServiceCloudConfig } from '@powersync/cli-schemas'; |
| 2 | +import { readFileSync } from 'node:fs'; |
| 3 | +import { Document, isMap, isNode, isPair, isScalar, isSeq, Pair, parseDocument, YAMLMap } from 'yaml'; |
| 4 | + |
| 5 | +export type BuildServiceYamlOptions = { |
| 6 | + /** |
| 7 | + * The service configuration to render in the form of the provided template. |
| 8 | + */ |
| 9 | + baseConfig: Partial<BaseServiceSelfHostedConfig | ServiceCloudConfig>; |
| 10 | + /** |
| 11 | + * A YAML comment block to attach to the top of the rendered YAML file, typically containing schema information and links to documentation. |
| 12 | + */ |
| 13 | + schemaHeader: string; |
| 14 | + /** |
| 15 | + * The absolute path to the YAML template file to use for rendering. This file should contain all possible configuration options with comments. |
| 16 | + */ |
| 17 | + templatePath: string; |
| 18 | + |
| 19 | + /** |
| 20 | + * For certain fields that are arrays of objects (e.g. replication->connections), if the provided config has an empty array, we want to replace it with a commented-out example from the template. This option allows specifying the paths to those fields in order to apply that replacement logic after the initial rendering of comments and missing fields. |
| 21 | + */ |
| 22 | + templateReplacementPaths?: string[][]; |
| 23 | +}; |
| 24 | + |
| 25 | +/** |
| 26 | + * Renders a service YAML file from a concrete base config and a parseable template. |
| 27 | + * Given a service config, the rendering process involves: |
| 28 | + * - Adding comments to fields from the template. |
| 29 | + * - Adding commented-out snippets for missing fields from the template, placed at the end of their respective sections. |
| 30 | + * - Preserving the order of fields as defined in the template. |
| 31 | + * |
| 32 | + * The output is a YAML string that can be written to a file, with comments and structure that guide users in filling out missing information. |
| 33 | + */ |
| 34 | +export function buildServiceYaml({ |
| 35 | + baseConfig, |
| 36 | + schemaHeader, |
| 37 | + templatePath, |
| 38 | + templateReplacementPaths |
| 39 | +}: BuildServiceYamlOptions): string { |
| 40 | + const templateContent = readFileSync(templatePath, 'utf8'); |
| 41 | + const templateDoc = parseDocument(templateContent); |
| 42 | + const outputDoc = new Document(baseConfig); |
| 43 | + |
| 44 | + // Both documents should be a YAML map at the root level. |
| 45 | + if (!isMap(templateDoc.contents) || !isMap(outputDoc.contents)) { |
| 46 | + throw new Error('Expected both template and base config to be YAML maps at the root level.'); |
| 47 | + } |
| 48 | + |
| 49 | + // Recursively starts annotating the provided config given the template. |
| 50 | + annotateLevelFromTemplate({ outputMap: outputDoc.contents, templateMap: templateDoc.contents }); |
| 51 | + |
| 52 | + /** |
| 53 | + * For some values, such as the replication->connections array or the client_auth->jwks->keys array, |
| 54 | + * we want to add a commented-out example from the template if the array (in the service config) is empty. |
| 55 | + * This allows users to see example options, and gives a quick path for them to configure the config. |
| 56 | + * This parsing is handled after the initial parsing of the template. |
| 57 | + * We want to avoid rendering incomplete YAML sections such as |
| 58 | + * ```yaml |
| 59 | + * replication: |
| 60 | + * connections: # No content here |
| 61 | + * # No content here |
| 62 | + * client_auth: |
| 63 | + * # etc |
| 64 | + * ``` |
| 65 | + * This typically causes a validation error. |
| 66 | + * Instead, we want to render the section with a commented-out example from the template, such as: |
| 67 | + * ```yaml |
| 68 | + * #replication: |
| 69 | + * # connections: |
| 70 | + * # - name: example-connection |
| 71 | + * # host: example.com |
| 72 | + * # port: 1234 |
| 73 | + * client_auth: |
| 74 | + * jwks: |
| 75 | + * ``` |
| 76 | + * This implements a replacement for those empty arrays. |
| 77 | + */ |
| 78 | + for (const { path, shouldReplace } of [ |
| 79 | + { |
| 80 | + path: ['replication', 'connections'], |
| 81 | + shouldReplace: baseConfig.replication?.connections?.length === 0 |
| 82 | + }, |
| 83 | + { path: ['client_auth', 'jwks', 'keys'], shouldReplace: baseConfig.client_auth?.jwks?.keys?.length === 0 }, |
| 84 | + ...(templateReplacementPaths?.map((path) => ({ path, shouldReplace: true })) ?? []) |
| 85 | + ]) { |
| 86 | + if (!shouldReplace) continue; |
| 87 | + replaceListWithComment({ |
| 88 | + outputRootDoc: outputDoc, |
| 89 | + path, |
| 90 | + templateDoc |
| 91 | + }); |
| 92 | + } |
| 93 | + |
| 94 | + const renderedYaml = outputDoc.toString(); |
| 95 | + return `${schemaHeader}\n\n${renderedYaml}`; |
| 96 | +} |
| 97 | + |
| 98 | +/** |
| 99 | + * Adds a comment to the target YAML node. The comment can be either a comment on the same line (comment) or a comment on the line before (commentBefore). |
| 100 | + */ |
| 101 | +function annotateComment(options: { |
| 102 | + appendComments?: boolean; |
| 103 | + comment?: null | string; |
| 104 | + commentBefore?: null | string; |
| 105 | + target: unknown; |
| 106 | +}): void { |
| 107 | + const { appendComments = true, comment, commentBefore, target } = options; |
| 108 | + if (!comment && !commentBefore) return; |
| 109 | + |
| 110 | + if (isScalar(target)) { |
| 111 | + if (comment) target.comment = appendComments ? (target.comment ?? '') + '\n' + comment : comment; |
| 112 | + if (commentBefore) |
| 113 | + target.commentBefore = appendComments ? commentBefore + '\n\n' + (target.commentBefore ?? '') : commentBefore; |
| 114 | + } else if (isPair(target) && isScalar(target.key)) { |
| 115 | + if (comment) target.key.comment = appendComments ? (target.key.comment ?? '') + '\n' + comment : comment; |
| 116 | + if (commentBefore) |
| 117 | + target.key.commentBefore = appendComments |
| 118 | + ? commentBefore + '\n\n' + (target.key.commentBefore ?? '') |
| 119 | + : commentBefore; |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +function copyNodeComments(source: unknown, target: unknown): void { |
| 124 | + if (!isNode(source) || !isNode(target)) return; |
| 125 | + target.comment = source.comment; |
| 126 | + target.commentBefore = source.commentBefore; |
| 127 | +} |
| 128 | + |
| 129 | +/** |
| 130 | + * Copies comments from the source YAML node to the target YAML node, including comments on the node itself, as well as comments on the key and value if the node is a pair. This ensures that all relevant comments from the template are preserved in the output YAML. |
| 131 | + */ |
| 132 | +function copyComments(options: { source: Pair; target: Pair }): void { |
| 133 | + const { source, target } = options; |
| 134 | + |
| 135 | + // Pair-level comments |
| 136 | + copyNodeComments(source, target); |
| 137 | + |
| 138 | + // Key comments |
| 139 | + copyNodeComments(source.key, target.key); |
| 140 | + |
| 141 | + if (isSeq(source.value) && isSeq(target.value) && target.value.items.length > 0) { |
| 142 | + // Avoid copying template block comments for sequences when we already have data |
| 143 | + // (e.g. replication->connections), otherwise example comments bleed into real configs. |
| 144 | + return; |
| 145 | + } |
| 146 | + |
| 147 | + // Value comments (covers scalars and maps that carry block comments before the value) |
| 148 | + copyNodeComments(source.value, target.value); |
| 149 | +} |
| 150 | + |
| 151 | +/** |
| 152 | + * Copies comments from the template to the output YAML at all levels, and appends optional template snippets for any missing keys. |
| 153 | + */ |
| 154 | +function annotateLevelFromTemplate(options: { outputMap: YAMLMap; templateMap: YAMLMap }): void { |
| 155 | + const { outputMap, templateMap } = options; |
| 156 | + // For items not present in the currentConfig, we'll add commented out snippets from the template at the end of the section. |
| 157 | + const missingPairs: Pair[] = []; |
| 158 | + |
| 159 | + for (const templatePair of templateMap.items) { |
| 160 | + const key = getPairKey(templatePair); |
| 161 | + if (!key) continue; |
| 162 | + |
| 163 | + const outputPair = outputMap.items.find((pair) => getPairKey(pair) === key); |
| 164 | + |
| 165 | + if (!outputMap.has(key) || !outputPair) { |
| 166 | + // We need to add this pair as a commented-out snippet later. |
| 167 | + missingPairs.push(templatePair); |
| 168 | + continue; |
| 169 | + } |
| 170 | + |
| 171 | + copyComments({ |
| 172 | + source: templatePair, |
| 173 | + target: outputPair |
| 174 | + }); |
| 175 | + |
| 176 | + // Add spacing between pairs |
| 177 | + if (isNode(outputPair.key) && isNode(templatePair.key)) { |
| 178 | + outputPair.key.spaceBefore = templatePair.key.spaceBefore; |
| 179 | + } |
| 180 | + |
| 181 | + // Walk the tree recursively for maps and sequences |
| 182 | + if (isMap(templatePair.value) && isMap(outputPair.value)) { |
| 183 | + annotateLevelFromTemplate({ outputMap: outputPair.value, templateMap: templatePair.value }); |
| 184 | + } else if (isSeq(templatePair.value) && isSeq(outputPair.value)) { |
| 185 | + for (const outputItem of outputPair.value.items) { |
| 186 | + // For sequences, we need a method to match the items. |
| 187 | + // We currently only deeply traverse replication->connections and client_auth->jwks->keys, which both have a "type" or "kty" field we can use for matching. |
| 188 | + const matchingTemplateItem = templatePair.value.items.find((templateItem) => { |
| 189 | + if (isMap(templateItem) && isMap(outputItem)) { |
| 190 | + return ( |
| 191 | + (templateItem.has('type') && |
| 192 | + outputItem.has('type') && |
| 193 | + templateItem.get('type') === outputItem.get('type')) || |
| 194 | + (templateItem.has('kty') && outputItem.has('kty') && templateItem.get('kty') === outputItem.get('kty')) |
| 195 | + ); |
| 196 | + } |
| 197 | + |
| 198 | + return false; |
| 199 | + }); |
| 200 | + if (!matchingTemplateItem) continue; |
| 201 | + annotateLevelFromTemplate({ |
| 202 | + outputMap: outputItem as YAMLMap, |
| 203 | + templateMap: matchingTemplateItem as YAMLMap |
| 204 | + }); |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + if (missingPairs.length > 0) { |
| 210 | + matchSorting({ |
| 211 | + orderedBy: templateMap.items, |
| 212 | + toBeSorted: missingPairs |
| 213 | + }); |
| 214 | + // The fact that we use a node comment means the indentation will be handled automatically. |
| 215 | + const snippets = missingPairs.map((pair) => pairToYaml(pair)).join('\n'); |
| 216 | + outputMap.comment = (outputMap.comment ? outputMap.comment + '\n' : '') + snippets; |
| 217 | + } |
| 218 | + |
| 219 | + matchSorting({ |
| 220 | + orderedBy: templateMap.items, |
| 221 | + toBeSorted: outputMap.items |
| 222 | + }); |
| 223 | +} |
| 224 | + |
| 225 | +function getPairKey(pair: Pair): null | string { |
| 226 | + if (!isScalar(pair.key)) return null; |
| 227 | + const { value } = pair.key; |
| 228 | + return typeof value === 'string' ? value : String(value); |
| 229 | +} |
| 230 | + |
| 231 | +function pairToYaml(pair: Pair): string { |
| 232 | + const map = new YAMLMap(); |
| 233 | + map.items = [pair]; |
| 234 | + return new Document(map).toString().trimEnd(); |
| 235 | +} |
| 236 | + |
| 237 | +function replaceListWithComment(options: { outputRootDoc: Document; path: string[]; templateDoc: Document }): void { |
| 238 | + // This is only true if the array is present and empty. |
| 239 | + // If the replication connections array is empty, add a commented-out example from the template. |
| 240 | + const { outputRootDoc, path, templateDoc } = options; |
| 241 | + |
| 242 | + const parentOutputContainer = outputRootDoc.getIn(path.slice(0, -1), true) as YAMLMap; |
| 243 | + if (!isMap(parentOutputContainer)) { |
| 244 | + throw new Error(`Expected parent of ${path.slice(0, -1).join('.')} to be a YAML map.`); |
| 245 | + } |
| 246 | + |
| 247 | + const finalOutputKey = path.at(-1); |
| 248 | + const outputItemIndex = parentOutputContainer.items.findIndex((pair) => getPairKey(pair) === finalOutputKey); |
| 249 | + |
| 250 | + if (outputItemIndex === -1) { |
| 251 | + throw new Error(`Expected ${finalOutputKey} key to be present in the output YAML.`); |
| 252 | + } |
| 253 | + |
| 254 | + const outputItem = parentOutputContainer.items[outputItemIndex]; |
| 255 | + |
| 256 | + const templatePair = templateDoc.getIn(path, true); |
| 257 | + const templateCommentString = pairToYaml(new Pair(finalOutputKey, templatePair)); |
| 258 | + |
| 259 | + /** |
| 260 | + * We don't want any empty objects/maps in the output YAML. |
| 261 | + * That causes validation errors and requires additional effort from users to input. |
| 262 | + * Instead, if a map is empty, we traverse on level up and add the map item as a comment on |
| 263 | + * that level. |
| 264 | + */ |
| 265 | + const siblingPair = parentOutputContainer.items[outputItemIndex + 1]; |
| 266 | + if (siblingPair) { |
| 267 | + // Add this example as a comment on the next sibling key. |
| 268 | + // We also need to preserve the current comment on the current key. |
| 269 | + const previousCommentBefore = |
| 270 | + (isScalar(outputItem.key) && outputItem.key.commentBefore && `${outputItem.key.commentBefore}\n`) || ''; |
| 271 | + annotateComment({ commentBefore: previousCommentBefore + templateCommentString, target: siblingPair }); |
| 272 | + } else if (path.length === 1) { |
| 273 | + // If there is no sibling and we are at the root, we just need to append it as a comment to the main doc |
| 274 | + parentOutputContainer.comment = (parentOutputContainer.comment || '') + templateCommentString; |
| 275 | + } else { |
| 276 | + // Move one level up and add the example as a comment on the parent key |
| 277 | + return replaceListWithComment({ |
| 278 | + outputRootDoc, |
| 279 | + path: path.slice(0, -1), |
| 280 | + templateDoc |
| 281 | + }); |
| 282 | + } |
| 283 | + |
| 284 | + // Delete the original yaml node |
| 285 | + parentOutputContainer.delete(outputItem.key); |
| 286 | +} |
| 287 | + |
| 288 | +/** |
| 289 | + * Sorts on array of YAML items by the order of another array's keyed values. |
| 290 | + */ |
| 291 | +function matchSorting(params: { orderedBy: Pair[]; toBeSorted: Pair[] }) { |
| 292 | + const { orderedBy, toBeSorted } = params; |
| 293 | + const orderMap = new Map(orderedBy.map((item, index) => [getPairKey(item), index])); |
| 294 | + |
| 295 | + toBeSorted.sort((a, b) => { |
| 296 | + const aKey = getPairKey(a); |
| 297 | + const bKey = getPairKey(b); |
| 298 | + const aIndex = aKey ? (orderMap.get(aKey) ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; |
| 299 | + const bIndex = bKey ? (orderMap.get(bKey) ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; |
| 300 | + return aIndex - bIndex; |
| 301 | + }); |
| 302 | +} |
0 commit comments