Skip to content

Commit 74397ba

Browse files
allow specifying which validations should be executed for all validation and deploy commands.
1 parent f471eac commit 74397ba

18 files changed

Lines changed: 833 additions & 581 deletions

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"dependencies": {
1111
"@fastify/cors": "^10.0.0",
1212
"@inquirer/prompts": "^7.2.0",
13+
"@journeyapps-labs/common-utils": "^1.0.1",
1314
"@oclif/core": "^4",
1415
"@oclif/plugin-autocomplete": "^3.2.40",
1516
"@oclif/plugin-commands": "^4.1.40",

cli/src/api/BaseDeployCommand.ts

Lines changed: 0 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,8 @@ import { routes } from '@powersync/management-types';
55
import { ObjectId } from 'bson';
66
import ora from 'ora';
77

8-
import { formatTestConnectionFailure, testCloudConnections } from './cloud/test-connection.js';
98
import { DEFAULT_DEPLOY_TIMEOUT_MS, waitForOperationStatusChange } from './cloud/wait-for-operation.js';
109

11-
export const SKIP_SYNC_CONFIG_VALIDATION_FLAG = {
12-
'skip-sync-config-validation': Flags.boolean({
13-
default: false,
14-
description: 'Skip sync config validation and continue with the deploy.'
15-
})
16-
};
17-
1810
export default abstract class BaseDeployCommand extends CloudInstanceCommand {
1911
static flags = {
2012
'deploy-timeout': Flags.integer({
@@ -157,42 +149,6 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
157149
}
158150
}
159151

160-
protected async testConnections(): Promise<void> {
161-
const { client, project } = this;
162-
const { linked } = project;
163-
const { serviceConfig } = this;
164-
if (!serviceConfig) {
165-
this.styledError({
166-
message: `Service config not loaded. Ensure ${SERVICE_FILENAME} is present and valid.`
167-
});
168-
}
169-
170-
this.log('\tTesting connections...');
171-
if ((serviceConfig.replication?.connections?.length ?? 0) <= 0) {
172-
this.styledError({
173-
message: 'No connection found in config.',
174-
suggestions: ['Add a connection to the config in replication->connections before deploying.']
175-
});
176-
}
177-
178-
const connectionResults = await testCloudConnections(
179-
client,
180-
linked,
181-
serviceConfig.replication?.connections ?? []
182-
).catch((error) => {
183-
this.styledError({
184-
error,
185-
message: 'Failed to test connections',
186-
suggestions: ['Check your network connection and try again.', 'If the problem persists, contact support.']
187-
});
188-
});
189-
for (const { connectionName, response } of connectionResults) {
190-
if (response.success !== true) {
191-
this.styledError({ message: formatTestConnectionFailure(response, connectionName) });
192-
}
193-
}
194-
}
195-
196152
protected async validateServiceConfig(params: { cloudConfigState: routes.InstanceConfigResponse }): Promise<void> {
197153
const { cloudConfigState } = params;
198154
const { linked } = this.project;
@@ -232,49 +188,6 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand {
232188
}
233189
}
234190

235-
protected async validateSyncConfig() {
236-
const { client, project } = this;
237-
// It might take a while for the instance to be fully provisioned after the deploy, so we retry the validation until it succeeds or we hit the timeout
238-
for (let retry = 0; retry < 100; retry++) {
239-
const validation = await client
240-
.validateSyncRules({
241-
app_id: project.linked.project_id,
242-
id: project.linked.instance_id,
243-
org_id: project.linked.org_id,
244-
sync_rules: project.syncRulesContent ?? ''
245-
})
246-
.catch((error) => {
247-
if (retry === 99) {
248-
this.styledError({
249-
error,
250-
message: `Failed to validate sync config for instance ${project.linked.instance_id} in project ${project.linked.project_id} in org ${project.linked.org_id}. Ensure the sync config is valid before deploying.`,
251-
suggestions: ['Check your sync config and try again.']
252-
});
253-
} else {
254-
// signal a retry
255-
return null;
256-
}
257-
});
258-
259-
if (!validation) {
260-
await new Promise((resolve) => {
261-
setTimeout(resolve, 1000);
262-
});
263-
continue;
264-
}
265-
266-
if (validation.errors.some((error) => error.level === 'fatal')) {
267-
this.styledError({
268-
message: `Sync config validation failed for instance. Validation errors:\n${validation.errors.map((error) => error.message).join('\n')}`,
269-
suggestions: ['Check your sync config and try again.']
270-
});
271-
}
272-
273-
// Validation succeeded with no errors
274-
return;
275-
}
276-
}
277-
278191
protected async waitForDeployCompletion(params: {
279192
deployResult: routes.DeployInstanceResponse;
280193
indentation?: string;
Lines changed: 6 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -1,258 +1,26 @@
1-
import { ux } from '@oclif/core';
2-
import {
3-
CloudProject,
4-
parseYamlFile,
5-
SelfHostedProject,
6-
SERVICE_FILENAME,
7-
SYNC_FILENAME,
8-
SyncDiagnostic,
9-
SyncValidationTestRunResult,
10-
validateCloudSyncRules,
11-
validateSelfHostedSyncRules,
12-
ValidationResult,
13-
ValidationTestResult,
14-
ValidationTestRunResult
15-
} from '@powersync/cli-core';
16-
import { ServiceCloudConfig, ServiceSelfHostedConfig } from '@powersync/cli-schemas';
17-
import { existsSync, readFileSync } from 'node:fs';
18-
import { join } from 'node:path';
19-
import { Document } from 'yaml';
20-
21-
/** Indentation used for nested human-readable output rows. */
22-
export const INDENT = ' ';
23-
24-
/** Bullet character used for list rows in human-readable output. */
25-
export const BULLET = '•';
1+
import { ValidationTestRunResult } from '@powersync/cli-core';
262

273
/**
284
* Definition of a test: display name and async runner function.
295
*/
306
export type ValidationTestDefinition = {
31-
name: string;
7+
name: ValidationTest;
328
run: () => Promise<ValidationTestRunResult>;
339
};
3410

3511
/**
3612
* Runtime test entry, storing the in-flight promise and optional settled result.
3713
*/
38-
export type ValidationTestEntry = {
14+
export type ValidationTestResultEntry = {
3915
name: string;
40-
promise: Promise<ValidationTestRunResult>;
4116
result?: ValidationTestRunResult;
4217
};
4318

4419
/**
4520
* Named test buckets used by the validate command.
4621
*/
4722
export enum ValidationTest {
48-
'CONFIGURATION-SCHEMA' = 'Validate Configuration Schema',
49-
'CONNECTIONS' = 'Test Connections',
50-
'SYNC-CONFIG' = 'Validate Sync Config'
51-
}
52-
53-
/**
54-
* Formats spinner text showing per-test progress while tests are running.
55-
* These logs are indeted with bullets for readability, and update in-place as each test settles to show pass/fail status.
56-
*/
57-
export function formatOraMessage(entries: ValidationTestEntry[]): string {
58-
return entries
59-
.map((e) => (e.result === undefined ? `\t... ${e.name}` : e.result.passed ? `\t✓ ${e.name}` : `\t✗ ${e.name}`))
60-
.join('\n');
61-
}
62-
63-
/**
64-
* Formats one test result into human-readable plain text.
65-
*/
66-
function formatTestResultHuman(test: ValidationTestResult): string {
67-
const status = test.passed ? '✓' : '✗';
68-
const name = `${status} ${test.name}`;
69-
const warningLines = (test.warnings ?? []).map((warning) => `${INDENT}${BULLET} [warning] ${warning}`);
70-
if (test.passed && warningLines.length === 0) return name;
71-
const errorLines = (test.errors ?? []).map((e) => `${INDENT}${BULLET} ${e}`);
72-
return [name, ...warningLines, ...errorLines].join('\n');
73-
}
74-
75-
/**
76-
* Formats suite output for `--output=human`.
77-
*/
78-
export function formatValidationHuman(result: ValidationResult): string {
79-
const header = result.passed ? 'All validation tests passed.' : 'Some validation tests failed.';
80-
const lines = [header, '', ...result.tests.map((test) => formatTestResultHuman(test))];
81-
return lines.join('\n');
82-
}
83-
84-
/**
85-
* Formats suite output for `--output=json`.
86-
*/
87-
export function formatValidationJson(result: ValidationResult): string {
88-
return JSON.stringify(result, null, 2);
89-
}
90-
91-
/**
92-
* Formats suite output for `--output=yaml`.
93-
*/
94-
export function formatValidationYaml(result: ValidationResult): string {
95-
return new Document(result).toString();
96-
}
97-
98-
/**
99-
* Builds a two-line diagnostic message containing a source fragment and location-prefixed message.
100-
*/
101-
function formatSyncDiagnosticMessage(diagnostic: SyncDiagnostic, syncRulesContent: string): string {
102-
const lineText = getLineAt(syncRulesContent, diagnostic.startLine);
103-
const fragment = getLineFragment(lineText, diagnostic.startColumn);
104-
105-
return `${fragment}\n${diagnostic.startLine}:${diagnostic.startColumn} ${diagnostic.message}`;
106-
}
107-
108-
/**
109-
* Retrieves a specific 1-based line from text content.
110-
*/
111-
function getLineAt(content: string, lineNumber: number): string {
112-
if (!content) {
113-
return '';
114-
}
115-
116-
const lines = content.split(/\r?\n/);
117-
return lines[Math.max(0, lineNumber - 1)] ?? '';
118-
}
119-
120-
/**
121-
* Extracts a nearby fragment around `startColumn`, clipping to a fixed width for readability.
122-
*/
123-
function getLineFragment(lineText: string, startColumn: number): string {
124-
if (!lineText) {
125-
return '(line unavailable)';
126-
}
127-
128-
const maxWidth = 120;
129-
if (lineText.length <= maxWidth) {
130-
return lineText;
131-
}
132-
133-
const centerIndex = Math.max(0, startColumn - 1);
134-
let start = Math.max(0, centerIndex - Math.floor(maxWidth / 2));
135-
const end = Math.min(lineText.length, start + maxWidth);
136-
137-
if (end - start < maxWidth) {
138-
start = Math.max(0, end - maxWidth);
139-
}
140-
141-
const prefix = start > 0 ? '…' : '';
142-
const suffix = end < lineText.length ? '…' : '';
143-
144-
return `${prefix}${lineText.slice(start, end)}${suffix}`;
145-
}
146-
147-
/**
148-
* Renders a warning into two lines for human output:
149-
* 1) gray source fragment
150-
* 2) yellow `[warning] line:column` prefix followed by plain message text
151-
*/
152-
export function renderWarningForHumanOutput(warning: string): string[] {
153-
const [fragmentRaw, locationAndMessageRaw] = warning.split('\n', 2);
154-
const fragment = fragmentRaw ?? '';
155-
const locationAndMessage = locationAndMessageRaw ?? '';
156-
157-
const parsed = locationAndMessage.match(/^(\d+:\d+)\s+([\s\S]+)$/);
158-
const location = parsed?.[1] ?? '';
159-
const message = parsed?.[2] ?? locationAndMessage;
160-
161-
const lines = [ux.colorize('gray', `${INDENT}${BULLET} ${fragment}`)];
162-
163-
if (location) {
164-
lines.push(`${INDENT}${ux.colorize('yellow', '[warning]')} ${ux.colorize('yellow', location)} ${message}`);
165-
} else {
166-
lines.push(`${INDENT}${ux.colorize('yellow', '[warning]')} ${message}`);
167-
}
168-
169-
return lines;
170-
}
171-
172-
/**
173-
* Validates `service.yaml` against the cloud or self-hosted schema, depending on project type.
174-
*/
175-
export async function runConfigTest(projectDir: string, isCloud: boolean): Promise<ValidationTestRunResult> {
176-
const servicePath = join(projectDir, SERVICE_FILENAME);
177-
try {
178-
const doc = parseYamlFile(servicePath);
179-
const raw = doc.contents?.toJSON();
180-
if (isCloud) {
181-
ServiceCloudConfig.decode(raw);
182-
} else {
183-
ServiceSelfHostedConfig.decode(raw);
184-
}
185-
186-
return { passed: true };
187-
} catch (error) {
188-
const message = error instanceof Error ? error.message : String(error);
189-
return { errors: [message], passed: false };
190-
}
191-
}
192-
193-
/**
194-
* Runs cloud sync-rules validation and maps diagnostics into warning/error message arrays.
195-
*/
196-
export async function runSyncConfigTestCloud(project: CloudProject): Promise<SyncValidationTestRunResult> {
197-
const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME);
198-
const syncRulesContent =
199-
project.syncRulesContent ?? (existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined);
200-
const syncText = syncRulesContent ?? '';
201-
202-
try {
203-
const result = await validateCloudSyncRules({
204-
linked: project.linked,
205-
syncRulesContent: syncText
206-
});
207-
208-
const errors = result.diagnostics
209-
.filter((diagnostic) => diagnostic.level === 'fatal')
210-
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
211-
const warnings = result.diagnostics
212-
.filter((diagnostic) => diagnostic.level === 'warning')
213-
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
214-
215-
return {
216-
// Add detailed diagnostics for errors and warnings.
217-
diagnostics: result.diagnostics,
218-
errors: errors.length > 0 ? errors : undefined,
219-
passed: errors.length === 0,
220-
warnings: warnings.length > 0 ? warnings : undefined
221-
};
222-
} catch (error) {
223-
const message = error instanceof Error ? error.message : String(error);
224-
return { diagnostics: [], errors: [message], passed: false };
225-
}
226-
}
227-
228-
/**
229-
* Runs self-hosted sync-rules validation and maps diagnostics into warning/error message arrays.
230-
*/
231-
export async function runSyncConfigTestSelfHosted(project: SelfHostedProject): Promise<SyncValidationTestRunResult> {
232-
const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME);
233-
const syncRulesContent = existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined;
234-
const syncText = syncRulesContent ?? '';
235-
try {
236-
const result = await validateSelfHostedSyncRules({
237-
linked: project.linked,
238-
syncRulesContent: syncText
239-
});
240-
241-
const errors = result.diagnostics
242-
.filter((diagnostic) => diagnostic.level === 'fatal')
243-
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
244-
const warnings = result.diagnostics
245-
.filter((diagnostic) => diagnostic.level === 'warning')
246-
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
247-
248-
return {
249-
diagnostics: result.diagnostics,
250-
errors: errors.length > 0 ? errors : undefined,
251-
passed: errors.length === 0,
252-
warnings: warnings.length > 0 ? warnings : undefined
253-
};
254-
} catch (error) {
255-
const message = error instanceof Error ? error.message : String(error);
256-
return { diagnostics: [], errors: [message], passed: false };
257-
}
23+
'CONFIGURATION' = 'configuration',
24+
'CONNECTIONS' = 'connections',
25+
'SYNC-CONFIG' = 'sync-config'
25826
}

0 commit comments

Comments
 (0)