From f30280d8bd17309a451779172567af9c59f67261 Mon Sep 17 00:00:00 2001 From: Abdullah Ibrahim Date: Wed, 8 Apr 2026 17:22:56 +0200 Subject: [PATCH 1/3] ci: add sync workflow to merge release tags back to main (#75) After each stable release on the release branch, this workflow automatically creates and merges a PR from release to main, ensuring release tags are reachable from main's history. Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/sync-release-to-main.yaml | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/sync-release-to-main.yaml diff --git a/.github/workflows/sync-release-to-main.yaml b/.github/workflows/sync-release-to-main.yaml new file mode 100644 index 0000000..a641658 --- /dev/null +++ b/.github/workflows/sync-release-to-main.yaml @@ -0,0 +1,54 @@ +name: Sync release to main + +on: + workflow_run: + workflows: [Release] + types: [completed] + branches: [release] + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Check if release is ahead of main + id: check + run: | + git fetch origin main release + AHEAD=$(git rev-list --count origin/main..origin/release) + echo "ahead=$AHEAD" >> $GITHUB_OUTPUT + + - name: Create sync PR + if: steps.check.outputs.ahead != '0' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING=$(gh pr list --repo ${{ github.repository }} --base main --head release --state open --json number --jq '.[0].number') + if [ -n "$EXISTING" ]; then + echo "Sync PR #$EXISTING already exists" + else + gh pr create \ + --repo ${{ github.repository }} \ + --base main \ + --head release \ + --title "chore: sync release to main" \ + --body "Automated sync of release tags back to main." + fi + + - name: Auto-merge sync PR + if: steps.check.outputs.ahead != '0' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR=$(gh pr list --repo ${{ github.repository }} --base main --head release --state open --json number --jq '.[0].number') + if [ -n "$PR" ]; then + gh pr merge "$PR" --repo ${{ github.repository }} --merge + fi From c080ce0aa79c45bf62fd4f4fd4bc02508bf1cb8f Mon Sep 17 00:00:00 2001 From: Abdullah Ibrahim Date: Wed, 8 Apr 2026 20:39:37 +0200 Subject: [PATCH 2/3] fix: always show pagination token when more results exist (#76) * fix: always show pagination token when more results exist Remove the isPaginated gate from output decisions. The pagination token and hint are now shown whenever the response indicates more items exist, regardless of whether --limit or --page-token were explicitly passed. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add pagination support to ls command The ls command also uses list() and listBuckets() from the SDK but was missing pagination flags and token display. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use consistent JSON schema for all list commands Always use formatPaginatedOutput so JSON output is consistently { "items": [...] } with optional paginationToken, rather than switching between a bare array and wrapped object based on server response. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add pagination support to snapshots list The storage SDK now supports pagination in listBucketSnapshots. Update the snapshots list command to accept --limit and --page-token flags and display the pagination token when more results exist. Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update @tigrisdata/storage with snapshot pagination support Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update fork detection to use new SDK forkInfo API The updated @tigrisdata/storage SDK replaced hasForks and sourceBucketName with forkInfo.hasChildren and forkInfo.parents[]. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- package-lock.json | 8 +++--- package.json | 2 +- src/lib/access-keys/list.ts | 14 ++++------ src/lib/buckets/list.ts | 24 ++++++++++------ src/lib/forks/list.ts | 7 +++-- src/lib/iam/policies/list.ts | 19 +++++++------ src/lib/ls.ts | 53 +++++++++++++++++++++++++++++++----- src/lib/objects/list.ts | 23 ++++++++-------- src/lib/snapshots/list.ts | 43 +++++++++++++++++++++++------ src/specs.yaml | 10 +++++++ 10 files changed, 144 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4f1cb28..fc5f849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@aws-sdk/credential-providers": "^3.1024.0", "@smithy/shared-ini-file-loader": "^4.4.7", "@tigrisdata/iam": "^1.4.1", - "@tigrisdata/storage": "^2.16.2", + "@tigrisdata/storage": "^3.0.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.2", @@ -4015,9 +4015,9 @@ } }, "node_modules/@tigrisdata/storage": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-2.16.2.tgz", - "integrity": "sha512-O17sUXp+8o5d+fjUwDYO4RhdRloJ3KazM+ICG7pbo682aKX5ROpnYH8zJn3qpzLn49CJy9TpdyknjuxrTX/aLA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@tigrisdata/storage/-/storage-3.0.0.tgz", + "integrity": "sha512-Rhw+aEOpl2bcgDhIymAguX2m178TYdco+lmX+zxYHw+P9jX8v4euwnZwRSb/+YwqmEawhBeapdNkCgIsBIVZ8g==", "license": "MIT", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", diff --git a/package.json b/package.json index d9b0bcc..4532e17 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "@aws-sdk/credential-providers": "^3.1024.0", "@smithy/shared-ini-file-loader": "^4.4.7", "@tigrisdata/iam": "^1.4.1", - "@tigrisdata/storage": "^2.16.2", + "@tigrisdata/storage": "^3.0.0", "commander": "^14.0.3", "enquirer": "^2.4.1", "jose": "^6.2.2", diff --git a/src/lib/access-keys/list.ts b/src/lib/access-keys/list.ts index 261fa25..6e6b71d 100644 --- a/src/lib/access-keys/list.ts +++ b/src/lib/access-keys/list.ts @@ -1,7 +1,7 @@ import { getIAMConfig } from '@auth/iam.js'; import { listAccessKeys } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; -import { formatOutput, formatPaginatedOutput } from '@utils/format.js'; +import { formatPaginatedOutput } from '@utils/format.js'; import { msg, printEmpty, @@ -17,7 +17,7 @@ export default async function list(options: Record) { printStart(context); const format = getFormat(options); - const { limit, pageToken, isPaginated } = getPaginationOptions(options); + const { limit, pageToken } = getPaginationOptions(options); const config = await getIAMConfig(context); @@ -52,15 +52,13 @@ export default async function list(options: Record) { const nextToken = data.paginationToken || undefined; - const output = isPaginated - ? formatPaginatedOutput(keys, format!, 'keys', 'key', columns, { - paginationToken: nextToken, - }) - : formatOutput(keys, format!, 'keys', 'key', columns); + const output = formatPaginatedOutput(keys, format!, 'keys', 'key', columns, { + paginationToken: nextToken, + }); console.log(output); - if (isPaginated && format !== 'json' && format !== 'xml') { + if (format !== 'json' && format !== 'xml') { printPaginationHint(nextToken); } diff --git a/src/lib/buckets/list.ts b/src/lib/buckets/list.ts index f59b0b8..243196f 100644 --- a/src/lib/buckets/list.ts +++ b/src/lib/buckets/list.ts @@ -28,7 +28,7 @@ export default async function list(options: Record) { } const { data, error } = await listBuckets({ - ...(forksOf || !isPaginated + ...(forksOf ? {} : { ...(limit !== undefined ? { limit } : {}), @@ -57,7 +57,7 @@ export default async function list(options: Record) { failWithError(context, infoError); } - if (!bucketInfo.hasForks) { + if (!bucketInfo.forkInfo?.hasChildren) { printEmpty(context); return; } @@ -67,7 +67,10 @@ export default async function list(options: Record) { for (const bucket of data.buckets) { if (bucket.name === forksOf) continue; const { data: info } = await getBucketInfo(bucket.name, { config }); - if (info?.sourceBucketName === forksOf) { + const isChildOf = info?.forkInfo?.parents?.some( + (p) => p.bucketName === forksOf + ); + if (isChildOf) { forks.push({ name: bucket.name, created: bucket.creationDate }); } } @@ -99,15 +102,18 @@ export default async function list(options: Record) { const nextToken = data.paginationToken || undefined; - const output = isPaginated - ? formatPaginatedOutput(buckets, format!, 'buckets', 'bucket', columns, { - paginationToken: nextToken, - }) - : formatOutput(buckets, format!, 'buckets', 'bucket', columns); + const output = formatPaginatedOutput( + buckets, + format!, + 'buckets', + 'bucket', + columns, + { paginationToken: nextToken } + ); console.log(output); - if (isPaginated && format !== 'json' && format !== 'xml') { + if (format !== 'json' && format !== 'xml') { printPaginationHint(nextToken); } diff --git a/src/lib/forks/list.ts b/src/lib/forks/list.ts index 5dee405..62a51cc 100644 --- a/src/lib/forks/list.ts +++ b/src/lib/forks/list.ts @@ -28,7 +28,7 @@ export default async function list(options: Record) { failWithError(context, infoError); } - if (!bucketInfo.hasForks) { + if (!bucketInfo.forkInfo?.hasChildren) { printEmpty(context); return; } @@ -47,7 +47,10 @@ export default async function list(options: Record) { if (bucket.name === name) continue; const { data: info } = await getBucketInfo(bucket.name, { config }); - if (info?.sourceBucketName === name) { + const isChildOf = info?.forkInfo?.parents?.some( + (p) => p.bucketName === name + ); + if (isChildOf) { forks.push({ name: bucket.name, created: bucket.creationDate, diff --git a/src/lib/iam/policies/list.ts b/src/lib/iam/policies/list.ts index 5004e0e..15ba76b 100644 --- a/src/lib/iam/policies/list.ts +++ b/src/lib/iam/policies/list.ts @@ -1,7 +1,7 @@ import { getOAuthIAMConfig } from '@auth/iam.js'; import { listPolicies } from '@tigrisdata/iam'; import { failWithError } from '@utils/exit.js'; -import { formatOutput, formatPaginatedOutput } from '@utils/format.js'; +import { formatPaginatedOutput } from '@utils/format.js'; import { msg, printEmpty, @@ -17,7 +17,7 @@ export default async function list(options: Record) { printStart(context); const format = getFormat(options); - const { limit, pageToken, isPaginated } = getPaginationOptions(options); + const { limit, pageToken } = getPaginationOptions(options); const iamConfig = await getOAuthIAMConfig(context); @@ -62,15 +62,18 @@ export default async function list(options: Record) { const nextToken = data.paginationToken || undefined; - const output = isPaginated - ? formatPaginatedOutput(policies, format!, 'policies', 'policy', columns, { - paginationToken: nextToken, - }) - : formatOutput(policies, format!, 'policies', 'policy', columns); + const output = formatPaginatedOutput( + policies, + format!, + 'policies', + 'policy', + columns, + { paginationToken: nextToken } + ); console.log(output); - if (isPaginated && format !== 'json' && format !== 'xml') { + if (format !== 'json' && format !== 'xml') { printPaginationHint(nextToken); } diff --git a/src/lib/ls.ts b/src/lib/ls.ts index 5451d8d..2bfb789 100644 --- a/src/lib/ls.ts +++ b/src/lib/ls.ts @@ -1,8 +1,9 @@ import { getStorageConfig } from '@auth/provider.js'; import { list, listBuckets } from '@tigrisdata/storage'; import { exitWithError } from '@utils/exit.js'; -import { formatOutput, formatSize } from '@utils/format.js'; -import { getFormat, getOption } from '@utils/options.js'; +import { formatPaginatedOutput, formatSize } from '@utils/format.js'; +import { printPaginationHint } from '@utils/messages.js'; +import { getFormat, getOption, getPaginationOptions } from '@utils/options.js'; import { parseAnyPath } from '@utils/path.js'; export default async function ls(options: Record) { @@ -13,11 +14,16 @@ export default async function ls(options: Record) { 'snapshot', ]); const format = getFormat(options); + const { limit, pageToken } = getPaginationOptions(options); if (!pathString) { // No path provided, list all buckets const config = await getStorageConfig(); - const { data, error } = await listBuckets({ config }); + const { data, error } = await listBuckets({ + ...(limit !== undefined ? { limit } : {}), + ...(pageToken ? { paginationToken: pageToken } : {}), + config, + }); if (error) { exitWithError(error); @@ -28,12 +34,28 @@ export default async function ls(options: Record) { created: bucket.creationDate, })); - const output = formatOutput(buckets, format!, 'buckets', 'bucket', [ + const columns = [ { key: 'name', header: 'Name' }, { key: 'created', header: 'Created' }, - ]); + ]; + + const nextToken = data.paginationToken || undefined; + + const output = formatPaginatedOutput( + buckets, + format!, + 'buckets', + 'bucket', + columns, + { paginationToken: nextToken } + ); console.log(output); + + if (format !== 'json' && format !== 'xml') { + printPaginationHint(nextToken); + } + return; } @@ -51,6 +73,8 @@ export default async function ls(options: Record) { const { data, error } = await list({ prefix, ...(snapshotVersion ? { snapshotVersion } : {}), + ...(limit !== undefined ? { limit } : {}), + ...(pageToken ? { paginationToken: pageToken } : {}), config: { ...config, bucket, @@ -84,11 +108,26 @@ export default async function ls(options: Record) { item.key !== '' && arr.findIndex((i) => i.key === item.key) === index ); - const output = formatOutput(objects, format!, 'objects', 'object', [ + const columns = [ { key: 'key', header: 'Key' }, { key: 'size', header: 'Size' }, { key: 'modified', header: 'Modified' }, - ]); + ]; + + const nextToken = data.paginationToken || undefined; + + const output = formatPaginatedOutput( + objects, + format!, + 'objects', + 'object', + columns, + { paginationToken: nextToken } + ); console.log(output); + + if (format !== 'json' && format !== 'xml') { + printPaginationHint(nextToken); + } } diff --git a/src/lib/objects/list.ts b/src/lib/objects/list.ts index 5234c0c..f9ff4b0 100644 --- a/src/lib/objects/list.ts +++ b/src/lib/objects/list.ts @@ -1,11 +1,7 @@ import { getStorageConfig } from '@auth/provider.js'; import { list } from '@tigrisdata/storage'; import { failWithError } from '@utils/exit.js'; -import { - formatOutput, - formatPaginatedOutput, - formatSize, -} from '@utils/format.js'; +import { formatPaginatedOutput, formatSize } from '@utils/format.js'; import { msg, printEmpty, @@ -29,7 +25,7 @@ export default async function listObjects(options: Record) { 'snapshotVersion', 'snapshot', ]); - const { limit, pageToken, isPaginated } = getPaginationOptions(options); + const { limit, pageToken } = getPaginationOptions(options); if (!bucketArg) { failWithError(context, 'Bucket name is required'); @@ -75,15 +71,18 @@ export default async function listObjects(options: Record) { const nextToken = data.paginationToken || undefined; - const output = isPaginated - ? formatPaginatedOutput(objects, format!, 'objects', 'object', columns, { - paginationToken: nextToken, - }) - : formatOutput(objects, format!, 'objects', 'object', columns); + const output = formatPaginatedOutput( + objects, + format!, + 'objects', + 'object', + columns, + { paginationToken: nextToken } + ); console.log(output); - if (isPaginated && format !== 'json' && format !== 'xml') { + if (format !== 'json' && format !== 'xml') { printPaginationHint(nextToken); } diff --git a/src/lib/snapshots/list.ts b/src/lib/snapshots/list.ts index 4875ef6..3cfbf9c 100644 --- a/src/lib/snapshots/list.ts +++ b/src/lib/snapshots/list.ts @@ -1,9 +1,15 @@ import { getStorageConfig } from '@auth/provider.js'; import { listBucketSnapshots } from '@tigrisdata/storage'; import { failWithError } from '@utils/exit.js'; -import { formatOutput } from '@utils/format.js'; -import { msg, printEmpty, printStart, printSuccess } from '@utils/messages.js'; -import { getFormat, getOption } from '@utils/options.js'; +import { formatPaginatedOutput } from '@utils/format.js'; +import { + msg, + printEmpty, + printPaginationHint, + printStart, + printSuccess, +} from '@utils/messages.js'; +import { getFormat, getOption, getPaginationOptions } from '@utils/options.js'; const context = msg('snapshots', 'list'); @@ -12,6 +18,7 @@ export default async function list(options: Record) { const name = getOption(options, ['name']); const format = getFormat(options); + const { limit, pageToken } = getPaginationOptions(options); if (!name) { failWithError(context, 'Bucket name is required'); @@ -19,29 +26,49 @@ export default async function list(options: Record) { const config = await getStorageConfig(); - const { data, error } = await listBucketSnapshots(name, { config }); + const { data, error } = await listBucketSnapshots(name, { + ...(limit !== undefined ? { limit } : {}), + ...(pageToken ? { paginationToken: pageToken } : {}), + config, + }); if (error) { failWithError(context, error); } - if (!data || data.length === 0) { + if (!data.snapshots || data.snapshots.length === 0) { printEmpty(context); return; } - const snapshots = data.map((snapshot) => ({ + const snapshots = data.snapshots.map((snapshot) => ({ name: snapshot.name || '', version: snapshot.version || '', created: snapshot.creationDate, })); - const output = formatOutput(snapshots, format!, 'snapshots', 'snapshot', [ + const columns = [ { key: 'name', header: 'Name' }, { key: 'version', header: 'Version' }, { key: 'created', header: 'Created' }, - ]); + ]; + + const nextToken = data.paginationToken || undefined; + + const output = formatPaginatedOutput( + snapshots, + format!, + 'snapshots', + 'snapshot', + columns, + { paginationToken: nextToken } + ); console.log(output); + + if (format !== 'json' && format !== 'xml') { + printPaginationHint(nextToken); + } + printSuccess(context, { count: snapshots.length }); } diff --git a/src/specs.yaml b/src/specs.yaml index e32655a..0692b7d 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -292,6 +292,11 @@ commands: description: Output format options: [json, table, xml] default: table + - name: limit + description: Maximum number of items to return per page + - name: page-token + description: Pagination token from a previous request to fetch the next page + alias: pt # mk - name: mk @@ -1115,6 +1120,11 @@ commands: description: Output format options: [json, table, xml] default: table + - name: limit + description: Maximum number of items to return per page + - name: page-token + description: Pagination token from a previous request to fetch the next page + alias: pt # take - name: take description: Take a new snapshot of the bucket's current state. Optionally provide a name for the snapshot From 72262916ff454aebb0f6c8ce9db24b17fee6bb28 Mon Sep 17 00:00:00 2001 From: Abdullah Ibrahim Date: Wed, 8 Apr 2026 20:49:34 +0200 Subject: [PATCH 3/3] fix: update integration tests for paginated JSON schema (#78) * fix: update integration tests for new paginated JSON schema JSON output from list commands now returns { items: [...] } instead of a flat array. Update the two integration tests that parsed JSON output as flat arrays to use parsed.items instead. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update stale bucket cleanup to use paginated JSON schema The beforeAll cleanup also parsed buckets list JSON as a flat array. Silently failed in the catch block, leaving stale test buckets. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- test/cli.test.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/test/cli.test.ts b/test/cli.test.ts index cd45a27..e0b06cc 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -275,12 +275,11 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { const listResult = runCli('buckets list --format json'); if (listResult.exitCode === 0 && listResult.stdout.trim()) { try { - const buckets = JSON.parse(listResult.stdout.trim()) as Array<{ - name: string; - created: string; - }>; + const parsed = JSON.parse(listResult.stdout.trim()) as { + items: Array<{ name: string; created: string }>; + }; const now = Date.now(); - for (const bucket of buckets) { + for (const bucket of parsed.items) { if (!bucket.name.startsWith('tigris-cli-test-')) continue; const age = now - new Date(bucket.created).getTime(); if (age > staleThresholdMs) { @@ -1156,10 +1155,10 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { const result = runCli('buckets list --format json'); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout.trim()); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.some((b: { name: string }) => b.name === testBucket)).toBe( - true - ); + expect(Array.isArray(parsed.items)).toBe(true); + expect( + parsed.items.some((b: { name: string }) => b.name === testBucket) + ).toBe(true); }); }); @@ -1810,10 +1809,10 @@ describe.skipIf(skipTests)('CLI Integration Tests', () => { const result = runCli(`snapshots list ${snapBucket} --format json`); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout.trim()); - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); + expect(Array.isArray(parsed.items)).toBe(true); + expect(parsed.items.length).toBeGreaterThan(0); // Save version for later tests - snapshotVersion = parsed[0].version; + snapshotVersion = parsed.items[0].version; expect(snapshotVersion).toBeTruthy(); });