From 61be0a4d58550d472e2a75147e6efbc4968e0118 Mon Sep 17 00:00:00 2001 From: michaelbarnes Date: Tue, 19 May 2026 19:59:57 -0600 Subject: [PATCH 1/3] feat: introduced the compact command for PowerSync Cloud, tests and the changeset. I also fixed the pre-existing issue with pnpm lint:fix --- .changeset/heavy-clowns-tell.md | 5 + .gitignore | 5 +- cli/README.md | 33 +++++- cli/src/commands/compact.ts | 64 ++++++++++++ cli/test/commands/compact.test.ts | 166 ++++++++++++++++++++++++++++++ cli/test/setup.ts | 2 + 6 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 .changeset/heavy-clowns-tell.md create mode 100644 cli/src/commands/compact.ts create mode 100644 cli/test/commands/compact.test.ts diff --git a/.changeset/heavy-clowns-tell.md b/.changeset/heavy-clowns-tell.md new file mode 100644 index 0000000..f0e82ad --- /dev/null +++ b/.changeset/heavy-clowns-tell.md @@ -0,0 +1,5 @@ +--- +'powersync': patch +--- + +Added **`powersync compact`** to trigger compaction on the linked PowerSync Cloud instance and reclaim sync bucket storage. diff --git a/.gitignore b/.gitignore index 2504c3d..14a46e8 100644 --- a/.gitignore +++ b/.gitignore @@ -145,4 +145,7 @@ playground/ .pnpm-store/ # MacOS -.DS_Store \ No newline at end of file +.DS_Store + +# IDE +.idea diff --git a/cli/README.md b/cli/README.md index 335c5c8..caac041 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 @@ -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) @@ -370,6 +371,36 @@ 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 ] [--instance-id --project-id ] [--org-id ] + +PROJECT FLAGS + --directory= [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= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. + --org-id= 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= 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 +``` + +_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. diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts new file mode 100644 index 0000000..4283bdc --- /dev/null +++ b/cli/src/commands/compact.ts @@ -0,0 +1,64 @@ +import { ux } from '@oclif/core'; +import { CloudInstanceCommand } from '@powersync/cli-core'; +import ora from 'ora'; + +import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; + +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 %>']; + static summary = '[Cloud only] Compact the linked Cloud instance.'; + + async run(): Promise { + const { flags } = await this.parse(Compact); + const { linked } = await this.loadProject(flags); + const { client } = this; + + 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: 30 * 60 * 1000 + }); + + 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.'] + }); + } + } +} diff --git a/cli/test/commands/compact.test.ts b/cli/test/commands/compact.test.ts new file mode 100644 index 0000000..a04b86d --- /dev/null +++ b/cli/test/commands/compact.test.ts @@ -0,0 +1,166 @@ +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('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); + }); + }); +}); diff --git a/cli/test/setup.ts b/cli/test/setup.ts index b32d818..695d419 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -34,6 +34,7 @@ export const MOCK_CLOUD_CONFIG: routes.InstanceConfigResponse = { } as const; export const managementClientMock = { + compact: vi.fn(), createInstance: vi.fn(), deactivateInstance: vi.fn(), deployInstance: vi.fn(), @@ -50,6 +51,7 @@ export function resetManagementClientMocks(): void { mockFn.mockReset(); } + managementClientMock.compact.mockRejectedValue(new Error('mock compact failure')); managementClientMock.createInstance.mockResolvedValue({ id: MOCK_CLOUD_IDS.instanceId }); managementClientMock.destroyInstance.mockRejectedValue(new Error('mock destroy failure')); managementClientMock.deactivateInstance.mockRejectedValue(new Error('mock deactivate failure')); From 28c66807a9fe747c548e1131545f77ebcca24c98 Mon Sep 17 00:00:00 2001 From: michaelbarnes Date: Tue, 19 May 2026 20:00:38 -0600 Subject: [PATCH 2/3] fix: fixes in migrate.test.ts --- cli/test/commands/migrate.test.ts | 38 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/cli/test/commands/migrate.test.ts b/cli/test/commands/migrate.test.ts index d6a34cd..43fd146 100644 --- a/cli/test/commands/migrate.test.ts +++ b/cli/test/commands/migrate.test.ts @@ -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 @@ -41,4 +44,5 @@ streams: queries: - SELECT * FROM lists WHERE owner_id = auth.user_id() `); + }); }); From 3582abbe8311ebca36185938e1f4f72de7578c29 Mon Sep 17 00:00:00 2001 From: michaelbarnes Date: Wed, 20 May 2026 09:31:05 -0600 Subject: [PATCH 3/3] feat: added --timeout flag --- .changeset/heavy-clowns-tell.md | 2 +- cli/README.md | 9 +++++++++ cli/src/commands/compact.ts | 20 +++++++++++++++++--- cli/test/commands/compact.test.ts | 7 +++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.changeset/heavy-clowns-tell.md b/.changeset/heavy-clowns-tell.md index f0e82ad..35f2098 100644 --- a/.changeset/heavy-clowns-tell.md +++ b/.changeset/heavy-clowns-tell.md @@ -2,4 +2,4 @@ 'powersync': patch --- -Added **`powersync compact`** to trigger compaction on the linked PowerSync Cloud instance and reclaim sync bucket storage. +Added **`powersync compact`** to trigger compaction on the linked PowerSync Cloud instance and reclaim sync bucket storage. Supports an optional `--timeout=` flag (default 30, use `0` to wait indefinitely) for long-running compactions on large buckets. diff --git a/cli/README.md b/cli/README.md index caac041..62e0f1b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -378,6 +378,11 @@ _See code: [@oclif/plugin-commands](https://github.com/oclif/plugin-commands/blo ``` USAGE $ powersync compact [--directory ] [--instance-id --project-id ] [--org-id ] + [--timeout ] + +FLAGS + --timeout= [default: 30] Maximum time to wait for compaction to complete, in minutes. Use 0 to wait + indefinitely. PROJECT FLAGS --directory= [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is @@ -397,6 +402,10 @@ DESCRIPTION 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)_ diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts index 4283bdc..2540d9b 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -1,18 +1,32 @@ -import { ux } from '@oclif/core'; +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 %>']; + 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 { 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, @@ -36,7 +50,7 @@ export default class Compact extends CloudInstanceCommand { instanceId: linked.instance_id, linked, operationId: compactResult.operation_id, - timeoutMs: 30 * 60 * 1000 + timeoutMs }); spinner.stop(); diff --git a/cli/test/commands/compact.test.ts b/cli/test/commands/compact.test.ts index a04b86d..845dd36 100644 --- a/cli/test/commands/compact.test.ts +++ b/cli/test/commands/compact.test.ts @@ -151,6 +151,13 @@ describe('compact', () => { 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'));