Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/src/api/validations/cloud-validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { routes } from '@powersync/management-types';

import { testCloudConnections } from '../cloud/test-connection.js';
import { mergeValidationTestRunResults } from './validation-utils.js';
import { runConfigTest, runSyncConfigTestCloud } from './validations.js';
import { runConfigTest, runSyncConfigTest } from './validations.js';
import { ValidationTest, ValidationTestDefinition } from './ValidationTestDefinition.js';

export type RunCloudValidationsOptions = {
Expand Down Expand Up @@ -139,7 +139,7 @@ export function getCloudValidations({
};
}

return runSyncConfigTestCloud(project);
return runSyncConfigTest(project);
}
}
];
Expand Down
4 changes: 2 additions & 2 deletions cli/src/api/validations/self-hosted-validations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { SelfHostedProject } from '@powersync/cli-core';

import { runConfigTest, runSyncConfigTestSelfHosted } from './validations.js';
import { runConfigTest, runSyncConfigTest } from './validations.js';
import { ValidationTest, ValidationTestDefinition } from './ValidationTestDefinition.js';

export type RunSelfHostedValidationsOptions = {
Expand All @@ -26,7 +26,7 @@ export function getSelfHostedValidationTests({
{
name: ValidationTest['SYNC-CONFIG'],
async run() {
return runSyncConfigTestSelfHosted(project);
return runSyncConfigTest(project);
}
}
] satisfies ValidationTestDefinition[];
Expand Down
92 changes: 3 additions & 89 deletions cli/src/api/validations/validation-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ux } from '@oclif/core';
import { SyncDiagnostic, ValidationResult, ValidationTestResult, ValidationTestRunResult } from '@powersync/cli-core';
import { ValidationResult, ValidationTestResult, ValidationTestRunResult } from '@powersync/cli-core';
import { Document } from 'yaml';

import { ValidationTest } from './ValidationTestDefinition.js';
Expand All @@ -25,24 +25,22 @@ export const STABLE_OUTPUT_NAMES: Record<ValidationTest, string> = {
/**
* Merges two or more `ValidationTestRunResult` objects into one.
* The merged result passes only if all inputs passed.
* Errors, warnings, and prettyOutput from all inputs are combined.
* Errors and warnings from all inputs are combined so callers can compose validation phases.
*/
export function mergeValidationTestRunResults(...results: ValidationTestRunResult[]): ValidationTestRunResult {
const errors = results.flatMap((r) => r.errors ?? []);
const warnings = results.flatMap((r) => r.warnings ?? []);
const prettyParts = results.map((r) => r.prettyOutput).filter(Boolean);

return {
errors: errors.length > 0 ? errors : undefined,
passed: results.every((r) => r.passed),
prettyOutput: prettyParts.length > 0 ? prettyParts.join('\n') : undefined,
warnings: warnings.length > 0 ? warnings : undefined
};
}

/**
* Formats spinner text showing per-test progress while tests are running.
* These logs are indeted with bullets for readability, and update in-place as each test settles to show pass/fail status.
* These logs are indented with bullets for readability, and update in-place as each test settles to show pass/fail status.
*/
export function formatOraMessage(
tests: ValidationTest[],
Expand All @@ -64,16 +62,9 @@ export function formatValidationErrorHuman(error: unknown): string {
return ux.colorize('red', `${INDENT}${BULLET} ${error}`);
}

/**
* Formats one test result into human-readable plain text.
*/
function formatTestResultHuman(test: ValidationTestResult): string {
const status = test.passed ? '✓' : '✗';
const name = `${status} ${STABLE_OUTPUT_NAMES[test.name as ValidationTest] ?? test.name}`;
if (test.prettyOutput) {
// Use custom pretty output if provided.
return `${name}\n${test.prettyOutput}`;
}

const warningLines = (test.warnings ?? []).map(
(warning) => `${INDENT}${BULLET} ${ux.colorize('yellow', '[warning]')} ${warning}`
Expand Down Expand Up @@ -107,80 +98,3 @@ export function formatValidationJson(result: ValidationResult): string {
export function formatValidationYaml(result: ValidationResult): string {
return new Document(result).toString();
}

/**
* Builds a two-line diagnostic message containing a source fragment and location-prefixed message.
*/
export function formatSyncDiagnosticMessage(diagnostic: SyncDiagnostic, syncRulesContent: string): string {
const lineText = getLineAt(syncRulesContent, diagnostic.startLine);
const fragment = getLineFragment(lineText, diagnostic.startColumn);

return `${fragment}\n${diagnostic.startLine}:${diagnostic.startColumn} ${diagnostic.message}`;
}

/**
* Retrieves a specific 1-based line from text content.
*/
function getLineAt(content: string, lineNumber: number): string {
if (!content) {
return '';
}

const lines = content.split(/\r?\n/);
return lines[Math.max(0, lineNumber - 1)] ?? '';
}

/**
* Extracts a nearby fragment around `startColumn`, clipping to a fixed width for readability.
*/
function getLineFragment(lineText: string, startColumn: number): string {
if (!lineText) {
return '(line unavailable)';
}

const maxWidth = 120;
if (lineText.length <= maxWidth) {
return lineText;
}

const centerIndex = Math.max(0, startColumn - 1);
let start = Math.max(0, centerIndex - Math.floor(maxWidth / 2));
const end = Math.min(lineText.length, start + maxWidth);

if (end - start < maxWidth) {
start = Math.max(0, end - maxWidth);
}

const prefix = start > 0 ? '…' : '';
const suffix = end < lineText.length ? '…' : '';

return `${prefix}${lineText.slice(start, end)}${suffix}`;
}

/**
* Renders a sync diagnostic into two lines for human output:
* 1) gray source fragment
* 2) colored `[error]` or `[warning]` label with `line:column` prefix followed by plain message text
*/
export function renderDiagnosticForHumanOutput(diagnostic: string, level: 'error' | 'warning'): string[] {
const [fragmentRaw, locationAndMessageRaw] = diagnostic.split('\n', 2);
const fragment = fragmentRaw ?? '';
const locationAndMessage = locationAndMessageRaw ?? '';

const parsed = locationAndMessage.match(/^(\d+:\d+)\s+([\s\S]+)$/);
const location = parsed?.[1] ?? '';
const message = parsed?.[2] ?? locationAndMessage;

const color = level === 'error' ? 'red' : 'yellow';
const label = level === 'error' ? '[error]' : '[warning]';

const lines = [ux.colorize('gray', `${INDENT}${BULLET} ${fragment}`)];

if (location) {
lines.push(`${INDENT}${ux.colorize(color, label)} ${ux.colorize(color, location)} ${message}`);
} else {
lines.push(`${INDENT}${ux.colorize(color, label)} ${message}`);
}

return lines;
}
92 changes: 38 additions & 54 deletions cli/src/api/validations/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
SERVICE_FILENAME,
SYNC_FILENAME,
SyncValidation,
SyncValidationError,
SyncValidationTestRunResult,
validateCloudSyncRules,
validateSelfHostedSyncRules,
validateProjectSyncConfig,
ValidationTestRunResult
} from '@powersync/cli-core';
import {
Expand All @@ -19,8 +19,6 @@ import {
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';

import { formatSyncDiagnosticMessage, renderDiagnosticForHumanOutput } from './validation-utils.js';

/**
* Validates `service.yaml` against the cloud or self-hosted schema, depending on project type.
*/
Expand All @@ -43,72 +41,58 @@ export async function runConfigTest(projectDir: string, isCloud: boolean): Promi
}

/**
* Wraps the sync validation with enhanced error and warning information.
* - Adds line numbers to locations of sync config errors.
* - Adds a pretty human-readable output string with color formatting for terminal display.
* Formats sync validation messages for CLI output.
* Core keeps `enrichedMessage` free of location prefixes so the editor can render location separately.
*/
function wrapsSyncValidation(params: { result: SyncValidation; syncText: string }): SyncValidationTestRunResult {
const { result, syncText } = params;
const errors = result.diagnostics
.filter((diagnostic) => diagnostic.level === 'fatal')
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
const warnings = result.diagnostics
.filter((diagnostic) => diagnostic.level === 'warning')
.map((diagnostic) => formatSyncDiagnosticMessage(diagnostic, syncText));
function formatSyncValidationErrorForCli(error: SyncValidationError): string {
if (!error.syncConfigLocation) {
return error.enrichedMessage;
}

const { column, line } = error.syncConfigLocation.start;
return `[Line ${line}, Column ${column}]: ${error.enrichedMessage}`;
}

const prettyOutput = [
...errors.map((line) => renderDiagnosticForHumanOutput(line, 'error').join('\n')),
...warnings.map((line) => renderDiagnosticForHumanOutput(line, 'warning').join('\n'))
].join('\n');
/**
* Wraps the sync validation with warning and error information for terminal display.
*/
function wrapsSyncValidation(result: SyncValidation): SyncValidationTestRunResult {
const errors = result.errors
.filter((issue) => issue.level === 'fatal')
.map((issue) => formatSyncValidationErrorForCli(issue));
const warnings = result.errors
.filter((issue) => issue.level === 'warning')
.map((issue) => formatSyncValidationErrorForCli(issue));

return {
diagnostics: result.diagnostics,
// Keep JSON/YAML output backward-compatible: these fields are optional and
// were historically omitted rather than emitted as empty arrays.
errors: errors.length > 0 ? errors : undefined,
passed: errors.length === 0,
prettyOutput,
warnings: warnings.length > 0 ? warnings : undefined
};
}

/**
* Runs cloud sync-rules validation and maps diagnostics into warning/error message arrays.
* Runs sync-config validation and maps warnings/errors into message arrays.
*/
export async function runSyncConfigTestCloud(project: CloudProject): Promise<SyncValidationTestRunResult> {
const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME);
const syncRulesContent =
project.syncRulesContent ?? (existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined);
const syncText = syncRulesContent ?? '';
export async function runSyncConfigTest(
project: CloudProject | SelfHostedProject
): Promise<SyncValidationTestRunResult> {
const syncConfigPath = join(project.projectDirectory, SYNC_FILENAME);
const syncConfigContent =
project.syncRulesContent ?? (existsSync(syncConfigPath) ? readFileSync(syncConfigPath, 'utf8') : undefined);

try {
return wrapsSyncValidation({
result: await validateCloudSyncRules({
linked: project.linked,
syncRulesContent: syncText
}),
syncText
});
return wrapsSyncValidation(
await validateProjectSyncConfig({
linkedProject: project,
syncConfigContent: syncConfigContent ?? ''
})
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { diagnostics: [], errors: [message], passed: false };
}
}

/**
* Runs self-hosted sync-rules validation and maps diagnostics into warning/error message arrays.
*/
export async function runSyncConfigTestSelfHosted(project: SelfHostedProject): Promise<SyncValidationTestRunResult> {
const syncRulesContent = project.syncRulesContent ?? '';
try {
return wrapsSyncValidation({
result: await validateSelfHostedSyncRules({
linked: project.linked,
syncRulesContent
}),
syncText: syncRulesContent
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { diagnostics: [], errors: [message], passed: false };
return { errors: [message], passed: false };
}
}

Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/deploy/sync-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ux } from '@oclif/core/ux';
import { WithSyncConfigFilePath } from '@powersync/cli-core';
import { ServiceCloudConfigDecoded } from '@powersync/cli-schemas';
import { routes } from '@powersync/management-types';
import { ObjectId } from 'bson';

Expand Down Expand Up @@ -101,7 +102,7 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC
_type: linked.type,
name: cloudConfigState.name,
...cloudConfigState.config
};
} as ServiceCloudConfigDecoded;

// Validate sync config
const instanceStatus = await this.client
Expand Down
15 changes: 7 additions & 8 deletions cli/test/commands/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from

const emptySyncValidation = {
connections: [] as { name?: string }[],
diagnostics: [] as cliCore.SyncDiagnostic[],
errors: [] as { level: string; message: string }[]
errors: [] as cliCore.SyncValidationError[]
};

type EnvSnapshot = {
Expand Down Expand Up @@ -52,7 +51,7 @@ describe('validate', () => {

describe('self-hosted', () => {
it('validates sync config from --sync-config-file-path, not default sync-config.yaml', async () => {
const spy = vi.spyOn(cliCore, 'validateSelfHostedSyncRules').mockResolvedValue(emptySyncValidation as never);
const spy = vi.spyOn(cliCore, 'validateProjectSyncConfig').mockResolvedValue(emptySyncValidation as never);

const projectDir = join(tmpRoot, 'powersync');
mkdirSync(projectDir, { recursive: true });
Expand Down Expand Up @@ -96,15 +95,15 @@ describe('validate', () => {

expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]![0];
expect(call.syncRulesContent).toContain('SELECT 1 FROM validate_custom_path_marker');
expect(call.syncRulesContent).not.toContain('only_in_default_sync_config_yaml');
expect(call.syncConfigContent).toContain('SELECT 1 FROM validate_custom_path_marker');
expect(call.syncConfigContent).not.toContain('only_in_default_sync_config_yaml');
});
});

describe('cloud', () => {
it('validates sync config from --sync-config-file-path when linked as cloud', async () => {
resetManagementClientMocks();
const spy = vi.spyOn(cliCore, 'validateCloudSyncRules').mockResolvedValue(emptySyncValidation as never);
const spy = vi.spyOn(cliCore, 'validateProjectSyncConfig').mockResolvedValue(emptySyncValidation as never);

const { instanceId, orgId, projectId } = MOCK_CLOUD_IDS;
const projectDir = join(tmpRoot, 'powersync');
Expand Down Expand Up @@ -166,8 +165,8 @@ describe('validate', () => {

expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0]![0];
expect(call.syncRulesContent).toContain('SELECT 1 FROM cloud_validate_override');
expect(call.syncRulesContent).not.toContain('cloud_default_file_only');
expect(call.syncConfigContent).toContain('SELECT 1 FROM cloud_validate_override');
expect(call.syncConfigContent).not.toContain('cloud_default_file_only');
});
});
});
2 changes: 0 additions & 2 deletions examples/self-hosted/local-postgres/powersync/service.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# TODO update this with published schema
# yaml-language-server: $schema=/Users/stevenontong/Documents/platform_code/powersync/new-cli/packages/schemas/json-schema/service-config.json
# yaml-language-server: $schema=https://unpkg.com/@powersync/cli-schemas@latest/json-schema/service-config.json
#
# PowerSync self-hosted config – example template with all schema options documented.
Expand Down
Loading
Loading