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
8 changes: 5 additions & 3 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,13 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync-
```
USAGE
$ powersync deploy sync-config [--deploy-timeout <value>] [--directory <value>] [--instance-id <value> --project-id
<value>] [--org-id <value>]
<value>] [--org-id <value>] [--sync-config-file-path <value>]

FLAGS
--deploy-timeout=<value> [default: 300] Seconds to wait after scheduling a deploy before timing out while polling
status (default 300 seconds).
--deploy-timeout=<value> [default: 300] Seconds to wait after scheduling a deploy before timing out while
polling status (default 300 seconds).
--sync-config-file-path=<value> Path to a sync config file. If provided, this file will be validated and deployed
instead of the default sync-config.yaml.

PROJECT FLAGS
--directory=<value> [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ export default class DeployAll extends CloudInstanceCommand {
});
}

override parseConfig(projectDirectory: string): ServiceCloudConfigDecoded {
const config = super.parseConfig(projectDirectory);
override parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded {
const config = super.parseLocalConfig(projectDirectory);

/**
* This is a temporary hack to maintain compatibilty with the PowerSync Dashboard.
Expand Down Expand Up @@ -175,7 +175,7 @@ export default class DeployAll extends CloudInstanceCommand {
const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000;

// Parse and store for later
this.parseConfig(project.projectDirectory);
this.parseLocalConfig(project.projectDirectory);

// The existing config is required to deploy changes. The instance should have been created already.
const cloudConfigState = await this.loadCloudConfigState();
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/deploy/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default class DeployServiceConfig extends DeployAll {
const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000;

// Parse and store for later
this.parseConfig(project.projectDirectory);
this.parseLocalConfig(project.projectDirectory);

// The existing config is required to deploy changes. The instance should have been created already.
const cloudConfigState = await this.loadCloudConfigState();
Expand Down
24 changes: 20 additions & 4 deletions cli/src/commands/deploy/sync-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Flags } from '@oclif/core';
import { ux } from '@oclif/core/ux';
import { routes } from '@powersync/management-types';
import { ObjectId } from 'bson';
import { readFileSync } from 'node:fs';

import { DEFAULT_DEPLOY_TIMEOUT_MS } from '../../api/cloud/wait-for-operation.js';
import DeployAll from './index.js';
Expand All @@ -12,7 +14,12 @@ export default class DeploySyncConfig extends DeployAll {
'<%= config.bin %> <%= command.id %> --instance-id=<id> --project-id=<id>'
];
static flags = {
...DeployAll.flags
...DeployAll.flags,
'sync-config-file-path': Flags.file({
description:
'Path to a sync config file. If provided, this file will be validated and deployed instead of the default sync-config.yaml.',
exists: true
})
};
static summary = '[Cloud only] Deploy only local sync config to the linked Cloud instance.';

Expand Down Expand Up @@ -65,18 +72,27 @@ export default class DeploySyncConfig extends DeployAll {
});

const { linked } = project;
this.parseConfig(project.projectDirectory);

const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000;

const syncConfigFilePath = flags['sync-config-file-path'];
if (syncConfigFilePath) {
project.syncRulesContent = readFileSync(syncConfigFilePath, 'utf8');
}

// The existing config is required to deploy changes. The instance should have been created already.
const cloudConfigState = await this.loadCloudConfigState();

if (!cloudConfigState.config) {
this.styledError({
message: `No existing cloud config found for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}. A config must be deployed before deploying sync config changes.`,
suggestions: [`Run ${ux.colorize('blue', 'powersync deploy')} to deploy the initial config.`]
});
}

// We use the cloud config as the "local config for this"
this.serviceConfig = {
_type: linked.type,
name: cloudConfigState.name,
region: cloudConfigState.config!.region!,
...cloudConfigState.config,
...linked
};
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/link/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default class LinkCloud extends CloudInstanceCommand {
this.styledError({ message: error instanceof Error ? error.message : String(error) });
}

const config = this.parseConfig(projectDirectory);
const config = this.parseLocalConfig(projectDirectory);
const { client } = this;

let newInstanceId: string;
Expand Down
175 changes: 175 additions & 0 deletions cli/test/commands/deploy/service-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Config } from '@oclif/core';
import { captureOutput } from '@oclif/test';
import { CLI_FILENAME, env, SERVICE_FILENAME } from '@powersync/cli-core';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import DeployServiceConfig from '../../../src/commands/deploy/service-config.js';
import { root } from '../../helpers/root.js';
import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../../setup.js';

const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS;

const MOCK_CLOUD_CONFIG = {
config: {
region: 'us',
replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] }
},
id: INSTANCE_ID,
name: 'test-instance',
sync_rules: /* yaml */ `
bucket_definitions:
global:
data:
- SELECT * FROM todos
`
};

const SERVICE_YAML_CONTENT = /* yaml */ `
_type: cloud
name: test-instance
region: us
replication:
connections:
- name: default
type: postgresql
uri: postgres://user:pass@host/db
`;

/** Run deploy:service-config by instantiating the command directly so the managementClientMock spy applies. */
async function runServiceConfigDirect(args: string[] = []) {
const config = await Config.load({ root });
const cmd = new DeployServiceConfig(args, config);
cmd.client = managementClientMock as unknown as DeployServiceConfig['client'];
return captureOutput(() => cmd.run());
}

function makeProjectDir(tmpDir: string, subDir = 'powersync'): string {
const projectDir = join(tmpDir, subDir);
mkdirSync(projectDir, { recursive: true });
return projectDir;
}

function writeServiceYaml(projectDir: string): void {
writeFileSync(join(projectDir, SERVICE_FILENAME), SERVICE_YAML_CONTENT, 'utf8');
}

function writeLinkYaml(projectDir: string): void {
const content = `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`;
writeFileSync(join(projectDir, CLI_FILENAME), content, 'utf8');
}

describe('deploy:service-config', () => {
let tmpDir: string;
let origCwd: string;
let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string };

beforeEach(() => {
resetManagementClientMocks();

origCwd = process.cwd();
origEnv = {
INSTANCE_ID: env.INSTANCE_ID,
ORG_ID: env.ORG_ID,
PROJECT_ID: env.PROJECT_ID,
PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN
};

tmpDir = mkdtempSync(join(tmpdir(), 'deploy-service-config-test-'));
process.chdir(tmpDir);
env.PS_ADMIN_TOKEN = 'test-token';
env.INSTANCE_ID = undefined;
env.ORG_ID = undefined;
env.PROJECT_ID = undefined;

managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG);
managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true });
managementClientMock.testConnection.mockResolvedValue({
configuration: { success: true },
connection: { reachable: true, success: true },
success: true
});
// deployInstance fails by default so tests don't need a real endpoint
managementClientMock.deployInstance.mockRejectedValue(new Error('mock deploy failure'));
});

afterEach(() => {
process.chdir(origCwd);

env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN;
env.INSTANCE_ID = origEnv.INSTANCE_ID;
env.ORG_ID = origEnv.ORG_ID;
env.PROJECT_ID = origEnv.PROJECT_ID;

if (tmpDir && existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
});

describe('succeeds without sync-config.yaml present', () => {
it('works with cli.yaml link file (no sync-config.yaml)', async () => {
const projectDir = makeProjectDir(tmpDir);
writeServiceYaml(projectDir);
writeLinkYaml(projectDir);
// Intentionally no sync-config.yaml written

const result = await runServiceConfigDirect();

// deployInstance is the last step; it failing means all prior validations passed
expect(result.error?.message).toMatch(/mock deploy failure/);
});

it('works with --instance-id / --project-id / --org-id flags (no sync-config.yaml)', async () => {
const projectDir = makeProjectDir(tmpDir);
writeServiceYaml(projectDir);
// No cli.yaml, no sync-config.yaml

const result = await runServiceConfigDirect([
'--instance-id',
INSTANCE_ID,
'--project-id',
PROJECT_ID,
'--org-id',
ORG_ID
]);

expect(result.error?.message).toMatch(/mock deploy failure/);
});

it('works with INSTANCE_ID / ORG_ID / PROJECT_ID env vars (no sync-config.yaml)', async () => {
const projectDir = makeProjectDir(tmpDir);
writeServiceYaml(projectDir);
// No cli.yaml, no sync-config.yaml

env.INSTANCE_ID = INSTANCE_ID;
env.ORG_ID = ORG_ID;
env.PROJECT_ID = PROJECT_ID;

const result = await runServiceConfigDirect();

expect(result.error?.message).toMatch(/mock deploy failure/);
});
});

it('errors when service.yaml is missing', async () => {
const projectDir = makeProjectDir(tmpDir);
writeLinkYaml(projectDir);
// No service.yaml

const result = await runServiceConfigDirect();

expect(result.error?.message).toMatch(/service\.yaml/);
expect(result.error?.oclif?.exit).toBe(1);
});

it('errors when linking info is absent entirely', async () => {
const projectDir = makeProjectDir(tmpDir);
writeServiceYaml(projectDir);
// No cli.yaml, no flags, no env vars

const result = await runServiceConfigDirect();

expect(result.error?.message).toMatch(/Linking is required/);
expect(result.error?.oclif?.exit).toBe(1);
});
});
Loading