Skip to content

Commit 87cbc99

Browse files
riderxcursoragent
andauthored
fix(cli): refuse delta uploads over 10k files + docs (#2759)
* fix(cli): refuse delta uploads over 10k files Match the set_manifest entry cap in the CLI before creating a version or uploading files, document the limit, and cover the backend rejection. Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com> * chore: retrigger CI after concurrency cancel Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com> * chore: retrigger CI for clean Run tests pass Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com> * fix(cli): explain delta 10k file limit to users Give a clear why/what-to-do message when refusing oversized delta uploads, and align API/docs wording with the same guidance. Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com> * fix(api): clarify set_manifest too-large error message Co-authored-by: Martin DONADIEU <martindonadieu@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent af0bda3 commit 87cbc99

8 files changed

Lines changed: 73 additions & 6 deletions

File tree

cli/skills/release-management/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Use this skill for OTA update workflows in Capgo Cloud.
7171
- `--zip`
7272
- `--tus`
7373
- `--tus-chunk-size <tusChunkSize>`
74-
- `--delta`
74+
- `--delta` (refuses over 10,000 files — delta tracks each file; use `--no-delta` for a full zip, or reduce build files)
7575
- `--delta-only`
7676
- `--no-delta`
7777
- `--encrypted-checksum <encryptedChecksum>`

cli/src/bundle/partial.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { parse } from '@std/semver'
1414
import * as micromatch from 'micromatch'
1515
import * as tus from 'tus-js-client'
1616
import { encryptChecksum, encryptChecksumV3, encryptSource } from '../api/crypto'
17-
import { BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, findRoot, generateManifest, getContentType, getInstalledVersion, getLocalConfig, isDeprecatedPluginVersion, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'
17+
import { BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, deltaManifestTooLargeMessage, findRoot, generateManifest, getContentType, getInstalledVersion, getLocalConfig, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, sendEvent, TUS_UPLOAD_RETRY_DELAYS } from '../utils'
1818

1919
// Check if file already exists on server (bypass cache and force storage lookup)
2020
async function fileExists(localConfig: any, filename: string): Promise<boolean> {
@@ -231,6 +231,10 @@ export async function uploadPartial(
231231
throw new Error(`Files with spaces in their names (${filesWithSpaces.map(f => f.file).join(', ')}). Please rename the files.`)
232232
}
233233

234+
if (manifest.length > MAX_MANIFEST_ENTRIES)
235+
throw new Error(deltaManifestTooLargeMessage(manifest.length))
236+
237+
234238
let uploadedFiles = 0
235239
const totalFiles = manifest.length
236240
let brFilesCount = 0

cli/src/bundle/upload.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences'
2323
import { showReplicationProgress } from '../replicationProgress'
2424
import { formatTable } from '../terminal-table'
2525
import { usesAlwaysDirectUpdate } from '../updaterConfig'
26-
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
26+
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
2727
import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers'
2828
import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta'
2929
import { checkIndexPosition, searchInDirectory } from './check'
@@ -1510,6 +1510,11 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
15101510
if (options.verbose && options.delta)
15111511
log.info(`[Verbose] Delta manifest prepared with ${manifest.length} files`)
15121512

1513+
// Refuse before creating a version row or uploading files (matches set_manifest cap).
1514+
if (options.delta && manifest.length > MAX_MANIFEST_ENTRIES)
1515+
uploadFail(deltaManifestTooLargeMessage(manifest.length))
1516+
1517+
15131518
if (options.verbose)
15141519
log.info(`[Verbose] Creating version record in database...`)
15151520

cli/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha
260260
.option('--tus-chunk-size <tusChunkSize>', `Chunk size in bytes for TUS resumable uploads (default: auto)`)
261261
.option('--partial', `[DEPRECATED] Use --delta instead. Upload incremental updates`)
262262
.option('--partial-only', `[DEPRECATED] Use --delta-only instead. Upload only incremental updates, skip full bundle`)
263-
.option('--delta', `Upload delta updates (only changed files) for instant, super-fast updates instead of big zip downloads`)
263+
.option('--delta', `Upload delta updates (only changed files) for instant, super-fast updates instead of big zip downloads. Capgo refuses delta uploads over 10,000 files (delta tracks each file); use --no-delta for a full zip, or reduce files in your web build.`)
264264
.option('--delta-only', `Upload only delta updates without full bundle for maximum speed (useful for large apps)`)
265265
.option('--no-delta', `Disable delta updates even if instant updates are enabled`)
266266
.option('--encrypted-checksum <encryptedChecksum>', `An encrypted checksum (signature). Used only when uploading an external bundle.`)

cli/src/utils.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,22 @@ export const ALERT_UPLOAD_SIZE_BYTES = 1024 * 1024 * 20 // 20MB
5252
export const MAX_UPLOAD_LENGTH_BYTES = 1024 * 1024 * 1024 // 1GB
5353
export const MAX_CHUNK_SIZE_BYTES = 1024 * 1024 * 99 // 99MB
5454
export const TUS_UPLOAD_RETRY_DELAYS = [0, 1000, 3000, 5000, 10000]
55+
// Keep in sync with supabase/functions/_backend/private/set_manifest.ts
56+
export const MAX_MANIFEST_ENTRIES = 10_000
57+
58+
/** User-facing error when a delta/manifest upload exceeds MAX_MANIFEST_ENTRIES. */
59+
export function deltaManifestTooLargeMessage(fileCount: number): string {
60+
const max = MAX_MANIFEST_ENTRIES.toLocaleString('en-US')
61+
const count = fileCount.toLocaleString('en-US')
62+
return [
63+
`Delta updates cannot upload this bundle: it has ${count} files, and Capgo allows at most ${max} files per delta (manifest) upload.`,
64+
'Delta mode tracks and uploads each file individually so devices can download only what changed. Very large file counts are not supported on that path.',
65+
'What you can do:',
66+
`1. Upload a full zip instead: npx @capgo/cli@latest bundle upload --no-delta`,
67+
'2. Or reduce files in your web build output (remove unused assets, avoid copying large trees into dist).',
68+
'See https://capgo.app/docs/faq/#are-there-delta-update-file-path-limitations',
69+
].join('\n')
70+
}
5571

5672
export const PACKNAME = 'package.json'
5773

cli/webdocs/bundle.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel prod
6969
| **--tus-chunk-size** | <code>string</code> | Chunk size in bytes for TUS resumable uploads (default: auto) |
7070
| **--partial** | <code>boolean</code> | [DEPRECATED] Use --delta instead. Upload incremental updates |
7171
| **--partial-only** | <code>boolean</code> | [DEPRECATED] Use --delta-only instead. Upload only incremental updates, skip full bundle |
72-
| **--delta** | <code>boolean</code> | Upload delta updates (only changed files) for instant, super-fast updates instead of big zip downloads |
72+
| **--delta** | <code>boolean</code> | Upload delta updates (only changed files) for instant, super-fast updates instead of big zip downloads. Capgo refuses delta uploads over 10,000 files because delta mode tracks each file individually; use --no-delta for a full zip, or reduce files in your web build. |
7373
| **--delta-only** | <code>boolean</code> | Upload only delta updates without full bundle for maximum speed (useful for large apps) |
7474
| **--no-delta** | <code>boolean</code> | Disable delta updates even if instant updates are enabled |
7575
| **--encrypted-checksum** | <code>string</code> | An encrypted checksum (signature). Used only when uploading an external bundle. |

supabase/functions/_backend/private/set_manifest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ app.post('/', middlewareKey(), async (c) => {
4343
if (!Array.isArray(body.manifest) || body.manifest.length === 0)
4444
return quickError(400, 'error_manifest_missing', 'Error manifest missing or empty', { body })
4545
if (body.manifest.length > MAX_MANIFEST_ENTRIES) {
46-
return quickError(400, 'error_manifest_too_large', 'Manifest has too many entries', {
46+
return quickError(400, 'error_manifest_too_large', `Delta manifests are limited to ${MAX_MANIFEST_ENTRIES} files (got ${body.manifest.length}). Use a full zip upload (--no-delta) or reduce files in the web build.`, {
4747
max: MAX_MANIFEST_ENTRIES,
4848
count: body.manifest.length,
4949
})

tests/set-manifest.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,48 @@ describe('[POST] /private/set_manifest', () => {
212212
expect(json.error).toBe('error_version_already_finalized')
213213
})
214214

215+
it('rejects manifests larger than 10,000 entries', async () => {
216+
const oversizedName = `${BUNDLE_NAME}-too-large`
217+
const { data: version, error } = await getSupabaseClient()
218+
.from('app_versions')
219+
.insert({
220+
app_id: APP_ID,
221+
name: oversizedName,
222+
checksum: randomUUID().replaceAll('-', ''),
223+
owner_org: ORG_ID,
224+
user_id: USER_ID,
225+
storage_provider: 'r2-direct',
226+
deleted: false,
227+
})
228+
.select('owner_org')
229+
.single()
230+
expect(error).toBeNull()
231+
232+
const prefix = `orgs/${version!.owner_org}/apps/${APP_ID}/delta`
233+
const oversized = Array.from({ length: 10_001 }, (_, i) => ({
234+
file_name: `f${i}.js`,
235+
s3_path: `${prefix}/h${i}_f${i}.js`,
236+
file_hash: `h${i}`,
237+
}))
238+
239+
const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), {
240+
method: 'POST',
241+
headers: {
242+
'Content-Type': 'application/json',
243+
'Authorization': APIKEY_TEST_ALL,
244+
},
245+
body: JSON.stringify({
246+
app_id: APP_ID,
247+
name: oversizedName,
248+
manifest: oversized,
249+
}),
250+
})
251+
252+
expect(response.status).toBe(400)
253+
const json = await response.json() as { error: string, max?: number, count?: number }
254+
expect(json.error).toBe('error_manifest_too_large')
255+
})
256+
215257
it('rejects missing versions and non-uploadable storage providers', async () => {
216258
const missing = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), {
217259
method: 'POST',

0 commit comments

Comments
 (0)