Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/heavy-clowns-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'powersync': patch
---

Added **`powersync compact`** to trigger compaction on the linked PowerSync Cloud instance and reclaim sync bucket storage. Supports an optional `--timeout=<minutes>` flag (default 30, use `0` to wait indefinitely) for long-running compactions on large buckets.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,7 @@ playground/
.pnpm-store/

# MacOS
.DS_Store
.DS_Store

# IDE
.idea
42 changes: 41 additions & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ $ npm install -g powersync
$ powersync COMMAND
running command...
$ powersync (--version)
powersync/0.9.4 linux-x64 node-v24.14.0
powersync/0.9.4 darwin-arm64 node-v24.12.0
$ powersync --help [COMMAND]
USAGE
$ powersync COMMAND
Expand Down Expand Up @@ -272,6 +272,7 @@ See [docs/usage.md](../docs/usage.md) for full usage and resolution order (flags

- [`powersync autocomplete [SHELL]`](#powersync-autocomplete-shell)
- [`powersync commands`](#powersync-commands)
- [`powersync compact`](#powersync-compact)
- [`powersync configure ide`](#powersync-configure-ide)
- [`powersync deploy`](#powersync-deploy)
- [`powersync deploy service-config`](#powersync-deploy-service-config)
Expand Down Expand Up @@ -370,6 +371,45 @@ DESCRIPTION

_See code: [@oclif/plugin-commands](https://github.com/oclif/plugin-commands/blob/v4.1.40/src/commands/commands.ts)_

## `powersync compact`

[Cloud only] Compact the linked Cloud instance.

```
USAGE
$ powersync compact [--directory <value>] [--instance-id <value> --project-id <value>] [--org-id <value>]
[--timeout <value>]

FLAGS
--timeout=<value> [default: 30] Maximum time to wait for compaction to complete, in minutes. Use 0 to wait
indefinitely.

PROJECT FLAGS
--directory=<value> [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is
required if multiple powersync config files are present in subdirectories of the current working
directory.

CLOUD_PROJECT FLAGS
--instance-id=<value> PowerSync Cloud instance ID. Manually passed if the current context has not been linked.
--org-id=<value> Organization ID (optional). Defaults to the token’s single org when only one is available; pass
explicitly if the token has multiple orgs.
--project-id=<value> Project ID. Manually passed if the current context has not been linked.

DESCRIPTION
[Cloud only] Compact the linked Cloud instance.

Trigger compaction on the linked PowerSync Cloud instance to reclaim sync bucket storage.

EXAMPLES
$ powersync compact

$ powersync compact --timeout=120

$ powersync compact --timeout=0
```

_See code: [src/commands/compact.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.4/src/commands/compact.ts)_

## `powersync configure ide`

Configure your IDE for the best PowerSync CLI developer experience.
Expand Down
78 changes: 78 additions & 0 deletions cli/src/commands/compact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Flags, ux } from '@oclif/core';
import { CloudInstanceCommand } from '@powersync/cli-core';
import ora from 'ora';

import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js';

const DEFAULT_COMPACT_TIMEOUT_MINUTES = 30;

export default class Compact extends CloudInstanceCommand {
static description = 'Trigger compaction on the linked PowerSync Cloud instance to reclaim sync bucket storage.';
static examples = [
'<%= config.bin %> <%= command.id %>',
'<%= config.bin %> <%= command.id %> --timeout=120',
'<%= config.bin %> <%= command.id %> --timeout=0'
];
static flags = {
timeout: Flags.integer({
default: DEFAULT_COMPACT_TIMEOUT_MINUTES,
description: 'Maximum time to wait for compaction to complete, in minutes. Use 0 to wait indefinitely.',
min: 0
})
};
static summary = '[Cloud only] Compact the linked Cloud instance.';

async run(): Promise<void> {
const { flags } = await this.parse(Compact);
const { linked } = await this.loadProject(flags);
const { client } = this;
const timeoutMs = flags.timeout === 0 ? Number.POSITIVE_INFINITY : flags.timeout * 60 * 1000;

const spinner = ora({
discardStdin: false,
prefixText: `\n${ux.colorize('yellow', 'Compacting')} instance ${ux.colorize('blue', linked.instance_id)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`,
spinner: 'moon',
suffixText: '\nThis may take a few minutes.\n'
});

spinner.start();

try {
const compactResult = await client.compact({
app_id: linked.project_id,
id: linked.instance_id,
org_id: linked.org_id
});

if (compactResult.operation_id) {
const status = await waitForOperationStatusChange({
client,
instanceId: linked.instance_id,
linked,
operationId: compactResult.operation_id,
timeoutMs
});

spinner.stop();

if (status === 'completed') {
this.log(ux.colorize('green', 'Instance compacted successfully.'));
} else {
this.styledError({
message: `Operation failed. Check instance diagnostics for details, for example: ${ux.colorize('blue', 'powersync status')}`
});
}
} else {
spinner.stop();
this.log(ux.colorize('green', 'Instance compacted successfully.'));
}
} catch (error) {
spinner.stop();
this.styledError({
error,
message: `Failed to compact instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}`,
suggestions: ['Check your network connection and try again.', 'If the problem persists, contact support.']
});
}
}
}
173 changes: 173 additions & 0 deletions cli/test/commands/compact.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Config } from '@oclif/core';
import { captureOutput, runCommand } from '@oclif/test';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import CompactCommand from '../../src/commands/compact.js';
import { root } from '../helpers/root.js';
import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../setup.js';

const CLI_FILENAME = 'cli.yaml';
const PROJECT_DIR = 'powersync';
const SERVICE_FILENAME = 'service.yaml';
const INSTANCE_ID = MOCK_CLOUD_IDS.instanceId;
const ORG_ID = MOCK_CLOUD_IDS.orgId;
const PROJECT_ID = MOCK_CLOUD_IDS.projectId;
const OPERATION_ID = 'op-compact-123';

function writeServiceYaml(projectDir: string, type: 'cloud' | 'self-hosted') {
writeFileSync(join(projectDir, SERVICE_FILENAME), `_type: ${type}\nregion: us\n`, 'utf8');
}

function writeLinkYaml(projectDir: string, opts: { instance_id: string; org_id: string; project_id: string }) {
const content = `type: cloud\ninstance_id: ${opts.instance_id}\norg_id: ${opts.org_id}\nproject_id: ${opts.project_id}\n`;
writeFileSync(join(projectDir, CLI_FILENAME), content, 'utf8');
}

describe('compact', () => {
let oclifConfig: Config;
let tmpDir: string;
let origCwd: string;
let origPsToken: string | undefined;

beforeAll(async () => {
oclifConfig = await Config.load({ root });
});

async function runCompactDirect(args: string[]) {
const cmd = new CompactCommand(args, oclifConfig);
cmd.client = managementClientMock as unknown as CompactCommand['client'];
return captureOutput(() => cmd.run());
}

beforeEach(() => {
resetManagementClientMocks();

origCwd = process.cwd();
origPsToken = process.env.PS_ADMIN_TOKEN;
tmpDir = mkdtempSync(join(tmpdir(), 'compact-test-'));
process.chdir(tmpDir);
process.env.PS_ADMIN_TOKEN = 'test-token';
});

afterEach(() => {
process.chdir(origCwd);
if (origPsToken === undefined) {
delete process.env.PS_ADMIN_TOKEN;
} else {
process.env.PS_ADMIN_TOKEN = origPsToken;
}

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

it('errors when directory does not exist', async () => {
const result = await runCommand('compact', { root });
expect(result.error?.message).toMatch(/Directory "powersync" not found/);
expect(result.error?.oclif?.exit).toBe(1);
});

it('errors when cli.yaml does not exist (missing link)', async () => {
const projectDir = join(tmpDir, PROJECT_DIR);
mkdirSync(projectDir, { recursive: true });
writeServiceYaml(projectDir, 'cloud');
const result = await runCommand('compact', { root });
expect(result.error?.message).toContain('Linking is required');
expect(result.error?.oclif?.exit).toBe(1);
});

it('errors when service.yaml _type is self-hosted', async () => {
const projectDir = join(tmpDir, PROJECT_DIR);
mkdirSync(projectDir, { recursive: true });
writeServiceYaml(projectDir, 'self-hosted');
writeLinkYaml(projectDir, { instance_id: 'i', org_id: 'o', project_id: 'p' });
const result = await runCommand('compact', { root });
expect(result.error?.message).toMatch(/has `_type: self-hosted` but this command requires `_type: cloud`/);
expect(result.error?.oclif?.exit).toBe(1);
});

describe('with valid cloud project', () => {
beforeEach(async () => {
const projectDir = join(tmpDir, PROJECT_DIR);
mkdirSync(projectDir, { recursive: true });
writeServiceYaml(projectDir, 'cloud');
writeLinkYaml(projectDir, { instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID });
});

it('calls compact on the management client with linked ids', async () => {
managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID });

await runCompactDirect([]);

expect(managementClientMock.compact).toHaveBeenCalledTimes(1);
expect(managementClientMock.compact).toHaveBeenCalledWith({
app_id: PROJECT_ID,
id: INSTANCE_ID,
org_id: ORG_ID
});
});

it('reports success when compact returns no operation_id', async () => {
managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID });

const result = await runCompactDirect([]);

expect(result.error).toBeUndefined();
expect(result.stdout).toContain('Instance compacted successfully.');
expect(managementClientMock.getInstanceStatus).not.toHaveBeenCalled();
});

it('polls operation status and reports success when status completes', async () => {
managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID, operation_id: OPERATION_ID });
managementClientMock.getInstanceStatus.mockResolvedValue({
operations: [{ id: OPERATION_ID, status: 'completed' }],
provisioned: true
});

const result = await runCompactDirect([]);

expect(managementClientMock.getInstanceStatus).toHaveBeenCalledWith({
app_id: PROJECT_ID,
id: INSTANCE_ID,
org_id: ORG_ID
});
expect(result.error).toBeUndefined();
expect(result.stdout).toContain('Instance compacted successfully.');
});

it('errors when polling reports a failed status', async () => {
managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID, operation_id: OPERATION_ID });
managementClientMock.getInstanceStatus.mockResolvedValue({
operations: [{ id: OPERATION_ID, status: 'failed' }],
provisioned: true
});

const result = await runCompactDirect([]);

expect(result.error?.message).toMatch(/Operation failed\. Check instance diagnostics/);
expect(result.error?.message).toContain('powersync status');
expect(result.error?.oclif?.exit).toBe(1);
});

it('rejects negative --timeout values', async () => {
const result = await runCompactDirect(['--timeout=-5']);
expect(result.error).toBeDefined();
expect(result.error?.message).toMatch(/Expected an integer greater than or equal to 0/);
expect(managementClientMock.compact).not.toHaveBeenCalled();
});

it('errors with exit 1 when client compact call fails (network error)', async () => {
managementClientMock.compact.mockRejectedValue(new Error('network down'));

const result = await runCompactDirect([]);

expect(result.error).toBeDefined();
expect(result.error?.message).toMatch(
new RegExp(`Failed to compact instance ${INSTANCE_ID} in project ${PROJECT_ID} in org ${ORG_ID}`)
);
expect(result.error?.oclif?.exit).toBe(1);
});
});
});
38 changes: 21 additions & 17 deletions cli/test/commands/migrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,36 @@ import { runCommand } from '@oclif/test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, onTestFinished, test } from 'vitest';
import { describe, expect, it, onTestFinished } from 'vitest';

import { root } from '../helpers/root.js';

test('migrates from sync rules to sync streams', async () => {
const testDirectory = mkdtempSync(join(tmpdir(), 'migrate-test-'));
onTestFinished(() => rmSync(testDirectory, { recursive: true }));
describe('migrate', () => {
it('migrates from sync rules to sync streams', async () => {
const testDirectory = mkdtempSync(join(tmpdir(), 'migrate-test-'));
onTestFinished(() => rmSync(testDirectory, { recursive: true }));

const inputFile = join(testDirectory, 'input.yaml');
const outputFile = join(testDirectory, 'output.yaml');
writeFileSync(
inputFile,
`
const inputFile = join(testDirectory, 'input.yaml');
const outputFile = join(testDirectory, 'output.yaml');
writeFileSync(
inputFile,
`
bucket_definitions:
user_lists:
parameters: SELECT request.user_id() as user_id
parameters: SELECT request.user_id() as user_id
data:
- SELECT * FROM lists WHERE owner_id = bucket.user_id
- SELECT * FROM lists WHERE owner_id = bucket.user_id
`
);
);

const result = await runCommand(`migrate sync-rules --input-file ${inputFile} --output-file ${outputFile}`, { root });
expect(result.error).toBeUndefined();
const result = await runCommand(`migrate sync-rules --input-file ${inputFile} --output-file ${outputFile}`, {
root
});
expect(result.error).toBeUndefined();

const transformed = readFileSync(outputFile).toString('utf-8');
expect(transformed)
.toStrictEqual(`# Adds YAML Schema support for VSCode users with the YAML extension installed. This enables features like validation and autocompletion based on the provided schema.
const transformed = readFileSync(outputFile).toString('utf8');
expect(transformed)
.toStrictEqual(`# Adds YAML Schema support for VSCode users with the YAML extension installed. This enables features like validation and autocompletion based on the provided schema.
# yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json
config:
edition: 3
Expand All @@ -41,4 +44,5 @@ streams:
queries:
- SELECT * FROM lists WHERE owner_id = auth.user_id()
`);
});
});
Loading
Loading