Skip to content

Commit 4620c7b

Browse files
feat: improved YAML comments for pull operations (#26)
1 parent b018f1e commit 4620c7b

22 files changed

Lines changed: 945 additions & 466 deletions

File tree

.changeset/twenty-carrots-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'powersync': patch
3+
---
4+
5+
Added `overwrite` flag to the `powersync pull instance` command. Specifying this flag will overwrite existing config files instead of writing to temporary files.

.changeset/witty-cobras-run.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'powersync': patch
3+
---
4+
5+
Added YAML comments to the configuration files genered when running powersync pull instance

cli/bin/dev.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@ setCliClientHeaders({
1111
'user-agent': `POWERSYNC_CLI/${packageJSON.version}`
1212
});
1313

14+
// Ensure pnpm scripts run in the shell's original cwd (pnpm sets INIT_CWD for this).
15+
if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) {
16+
process.chdir(process.env.INIT_CWD);
17+
}
18+
1419
const __dirname = path.dirname(fileURLToPath(import.meta.url));
1520

1621
await execute({
1722
development: true,
18-
dir: import.meta.url,
23+
// Force oclif to ignore the baked manifest so it resolves commands from TS via tsx.
1924
loadOptions: {
25+
ignoreManifest: true,
2026
root: path.resolve(__dirname, '..')
2127
}
2228
});

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
"scripts": {
121121
"dev": "tsx bin/dev.js",
122122
"clean": "rm -rf dist tsconfig.tsbuildinfo",
123-
"build": "tsc -b && pnpm prepack",
123+
"build": "tsc -b",
124124
"lint": "eslint",
125125
"postpack": "shx rm -f oclif.manifest.json",
126126
"pretest": "pnpm run build",

cli/src/api/build-service-yaml.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { CLI_FILENAME, SYNC_FILENAME, YAML_CLI_SCHEMA, YAML_SYNC_RULES_SCHEMA } from '@powersync/cli-core';
2+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3+
import { dirname, join } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
const __dirname = dirname(fileURLToPath(import.meta.url));
7+
8+
export const CLOUD_TEMPLATES_DIR = join(__dirname, '..', '..', '..', 'templates', 'cloud', 'powersync');
9+
10+
export const SERVICE_TEMPLATE_FILENAME = 'service.template.yaml';
11+
12+
export const CLOUD_SERVICE_TEMPLATE_PATH = join(CLOUD_TEMPLATES_DIR, SERVICE_TEMPLATE_FILENAME);
13+
export const CLOUD_SYNC_CONFIG_TEMPLATE_PATH = join(CLOUD_TEMPLATES_DIR, SYNC_FILENAME);
14+
export const CLOUD_CLI_TEMPLATE_PATH = join(CLOUD_TEMPLATES_DIR, CLI_FILENAME);
15+
16+
export type WriteCloudTemplateFilesParams = {
17+
targetDir: string;
18+
};
19+
20+
export async function writeCloudSyncConfigFile(params: WriteCloudTemplateFilesParams) {
21+
const { targetDir } = params;
22+
const syncOutputPath = join(targetDir, SYNC_FILENAME);
23+
24+
await copyFile(CLOUD_SYNC_CONFIG_TEMPLATE_PATH, syncOutputPath);
25+
await writeFile(syncOutputPath, `${YAML_SYNC_RULES_SCHEMA}\n\n${await readFile(syncOutputPath, 'utf8')}`);
26+
}
27+
28+
/**
29+
* Copies the cloud template files to the provided destination.
30+
* YAML schemas are applied to copied files.
31+
* This copies the template Sync config and CLI config files.
32+
*/
33+
export async function writeCloudTemplateFiles(params: WriteCloudTemplateFilesParams): Promise<void> {
34+
const { targetDir } = params;
35+
36+
// Create the target directory if it doesn't exist
37+
await mkdir(targetDir, { recursive: true });
38+
39+
// Initial copy of template files
40+
const cliOutputPath = join(targetDir, CLI_FILENAME);
41+
42+
await copyFile(CLOUD_CLI_TEMPLATE_PATH, cliOutputPath);
43+
// Add schemas to templates
44+
await writeFile(cliOutputPath, `${YAML_CLI_SCHEMA}\n\n${await readFile(cliOutputPath, 'utf8')}`);
45+
46+
await writeCloudSyncConfigFile(params);
47+
}

0 commit comments

Comments
 (0)