diff --git a/cli/README.md b/cli/README.md index d0d8496e5b..9a2060d824 100644 --- a/cli/README.md +++ b/cli/README.md @@ -948,6 +948,7 @@ npx @capgo/cli@latest channel set production com.example.app --bundle 1.0.0 --st | **--self-assign** | boolean | Allow device to self-assign to this channel | | **--no-self-assign** | boolean | Disable devices to self-assign to this channel | | **--disable-auto-update** | string | Block updates by type: major, minor, metadata, patch, or none (allows all) | +| **--update-package** | string | Serve zip, delta, or both: all, zip, delta, zip_from_builtin, or delta_from_builtin | | **--rollout-bundle** | string | Bundle version to release gradually on this channel | | **--rollout-percentage** | string | Rollout percentage from 0 to 100 | | **--rollout-percentage-bps** | string | Rollout percentage in basis points from 0 to 10000 | diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index c9f909f878..dec80d1e2c 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -25,7 +25,7 @@ import { showReplicationProgress } from '../replicationProgress' import { CliUserError } from '../shared/cli-user-error' import { formatTable } from '../terminal-table' import { usesAlwaysDirectUpdate } from '../updaterConfig' -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, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, UPLOAD_TIMEOUT_ERROR_NAME, uploadTimeoutMessage, uploadTUS, uploadUrl, zipFile } from '../utils' +import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, channelUpdatePackageCliError, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, UPLOAD_TIMEOUT_ERROR_NAME, uploadTimeoutMessage, uploadTUS, uploadUrl, zipFile } from '../utils' import type { AutoBumpLevel } from '../versionHelpers' import { autoBumpVersionBy, getVersionSuggestions, interactiveVersionBump, normalizeAutoBumpInput } from '../versionHelpers' import { resolveAutoBumpLevelFromAi } from './auto-bump-ai' @@ -65,6 +65,11 @@ function uploadFail(message: string): never { throw new CliUserError(message) } +async function uploadFailIfChannelError(error: unknown, fallback: () => string | Promise): Promise { + const packageError = await channelUpdatePackageCliError(error) + uploadFail(packageError || await fallback()) +} + // A user-initiated cancel is an expected exit, not a crash: warn instead of // error, and throw `CliUserError` so error tracking skips it. function uploadCancel(): never { @@ -1025,8 +1030,9 @@ async function promoteExistingChannel( supaAnon: options?.supaAnon, }) - if (error) - uploadFail(`Cannot set channel because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) + if (error) { + await uploadFailIfChannelError(error, async () => `Cannot set channel because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) + } const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${targetChannel.id}` if (targetChannel.public) @@ -1088,16 +1094,18 @@ async function setVersionInChannel( owner_org: orgId, ...(selfAssign ? { allow_device_self_set: true } : {}), }) - if (dbError3) - uploadFail(`Cannot set channel because this API key does not have the required RBAC permission. ${formatError(dbError3)}`) - const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}` - if (data?.public) - log.info('Your update is now available in your public channel 🎉') - else if (data?.id) - log.info(`Link device to this bundle to try it: ${bundleUrl}`) - - if (displayBundleUrl) - log.info(`Bundle url: ${bundleUrl}`) + if (dbError3) { + await uploadFailIfChannelError(dbError3, () => `Cannot set channel because this API key does not have the required RBAC permission. ${formatError(dbError3)}`) + } + if (data?.id) { + const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}` + if (data.public) + log.info('Your update is now available in your public channel 🎉') + else + log.info(`Link device to this bundle to try it: ${bundleUrl}`) + if (displayBundleUrl) + log.info(`Bundle url: ${bundleUrl}`) + } return true } @@ -1117,7 +1125,7 @@ async function setVersionInChannel( supaAnon: cliHost?.supaAnon, }) if (error) { - uploadFail(`Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) + await uploadFailIfChannelError(error, async () => `Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`) } const createdChannel = data as { id?: unknown, public?: unknown } | null @@ -1204,8 +1212,9 @@ async function setRolloutVersionInChannel( supaAnon: cliHost?.supaAnon, }) - if (rolloutError) - uploadFail(`Cannot set rollout in channel ${await formatFunctionInvokeError(rolloutError)}`) + if (rolloutError) { + await uploadFailIfChannelError(rolloutError, async () => `Cannot set rollout in channel ${await formatFunctionInvokeError(rolloutError)}`) + } const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${targetChannel.id}` log.info(`Set ${appid} channel ${channel} rollout target to @${bundle} (${formatRolloutPercentage(rolloutPercentageBps)})`) diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index 75cf62d63d..b37210ac93 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -8,7 +8,7 @@ import { getActiveAppVersions, getVersionData } from '../api/versions' import { sendUpdateNotificationsForChannels } from '../notifications/send-update' import { printPreviewQrForResolvedTarget, resolveChannelPreviewTarget } from '../preview/qr' import { formatTable } from '../terminal-table' -import { checkCompatibilityNativePackages, checkPlanValid, createSupabaseClient, findSavedKey, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getOrganizationId, invokeCapgoCliApi, isCompatible, resolveUserIdFromApiKey, sendEvent } from '../utils' +import { channelUpdatePackageCliError, checkCompatibilityNativePackages, checkPlanValid, createSupabaseClient, findSavedKey, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getOrganizationId, invokeCapgoCliApi, isCompatible, resolveUserIdFromApiKey, sendEvent } from '../utils' /** * Display a compatibility table for the given packages @@ -34,6 +34,7 @@ function displayCompatibilityTable(packages: Compatibility[]) { export type { OptionsSetChannel } from '../schemas/channel' const disableAutoUpdatesPossibleOptions = ['major', 'minor', 'metadata', 'patch', 'none'] +const updatePackagePossibleOptions = ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin'] as const function assertIntegerInRange(value: number, label: string, min: number, max: number) { if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max) @@ -93,6 +94,7 @@ export async function setChannelInternal(channel: string, appId: string, options android, selfAssign, disableAutoUpdate, + updatePackage, dev, emulator, device, @@ -152,6 +154,7 @@ export async function setChannelInternal(channel: string, appId: string, options && device == null && prod == null && disableAutoUpdate == null + && updatePackage == null && rolloutBundle == null && rolloutPercentage == null && rolloutPercentageBps == null @@ -201,6 +204,7 @@ export async function setChannelInternal(channel: string, appId: string, options || android != null || selfAssign != null || disableAutoUpdate != null + || updatePackage != null || dev != null || emulator != null || device != null @@ -577,6 +581,17 @@ export async function setChannelInternal(channel: string, appId: string, options log.info(`Set ${appId} channel: ${channel} to ${finalDisableAutoUpdate} disable update strategy to this channel`) } + if (updatePackage != null) { + if (!updatePackagePossibleOptions.includes(updatePackage)) { + if (!silent) + log.error(`Update package ${updatePackage} is not known. The possible values are: ${updatePackagePossibleOptions.join(', ')}.`) + throw new Error(`Unknown update package ${updatePackage}`) + } + channelPayload.update_package = updatePackage + if (!silent) + log.info(`Set ${appId} channel: ${channel} update package to ${updatePackage}`) + } + if (hasStableBundlePromotion && !hasSettingsUpdate) { const { error } = await invokeCapgoCliApi('bundle', { apikey: options.apikey!, @@ -590,6 +605,12 @@ export async function setChannelInternal(channel: string, appId: string, options supaAnon: options.supaAnon, }) if (error) { + const packageError = await channelUpdatePackageCliError(error) + if (packageError) { + if (!silent) + log.error(packageError) + throw new Error(packageError) + } if (!silent) log.error('Cannot set channel because this API key does not have the required RBAC permission.') throw new Error('API key is not allowed to set this channel') @@ -627,6 +648,8 @@ export async function setChannelInternal(channel: string, appId: string, options channelBody.disableAutoUpdateUnderNative = channelPayload.disable_auto_update_under_native if (channelPayload.disable_auto_update !== undefined) channelBody.disableAutoUpdate = channelPayload.disable_auto_update + if (channelPayload.update_package !== undefined) + channelBody.updatePackage = channelPayload.update_package if (channelPayload.ios !== undefined) channelBody.ios = channelPayload.ios if (channelPayload.android !== undefined) @@ -696,6 +719,12 @@ export async function setChannelInternal(channel: string, appId: string, options supaAnon: options.supaAnon, }) if (dbError) { + const packageError = await channelUpdatePackageCliError(dbError) + if (packageError) { + if (!silent) + log.error(packageError) + throw new Error(packageError) + } if (!silent) log.error('Cannot set channel because this API key does not have the required RBAC permission.') throw new Error('API key is not allowed to set this channel') diff --git a/cli/src/index.ts b/cli/src/index.ts index 54b202d22a..e8fc341bf8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -592,6 +592,7 @@ Example: npx @capgo/cli@latest channel set production com.example.app --bundle 1 .option('--self-assign', `Allow device to self-assign to this channel`) .option('--no-self-assign', `Disable devices to self-assign to this channel`) .option('--disable-auto-update ', `Block updates by type: major, minor, metadata, patch, or none (allows all)`) + .option('--update-package ', `Serve zip, delta, or both: all, zip, delta, zip_from_builtin, or delta_from_builtin`) .option('--rollout-bundle ', `Bundle version to release gradually on this channel`) .option('--rollout-percentage ', `Rollout percentage from 0 to 100`, value => Number.parseFloat(value)) .option('--rollout-percentage-bps ', `Rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10)) diff --git a/cli/src/schemas/channel.ts b/cli/src/schemas/channel.ts index e2d8634a36..df9dadb2ce 100644 --- a/cli/src/schemas/channel.ts +++ b/cli/src/schemas/channel.ts @@ -61,6 +61,7 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({ android: z.boolean().optional(), selfAssign: z.boolean().optional(), disableAutoUpdate: z.string().optional(), + updatePackage: z.enum(['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin']).optional(), dev: z.boolean().optional(), emulator: z.boolean().optional(), device: z.boolean().optional(), diff --git a/cli/src/schemas/sdk.ts b/cli/src/schemas/sdk.ts index e3e8b06efc..f3747a3d74 100644 --- a/cli/src/schemas/sdk.ts +++ b/cli/src/schemas/sdk.ts @@ -203,6 +203,7 @@ export const updateChannelOptionsBaseSchema = z.object({ android: z.boolean().optional(), selfAssign: z.boolean().optional(), disableAutoUpdate: z.string().optional(), + updatePackage: z.enum(['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin']).optional(), dev: z.boolean().optional(), emulator: z.boolean().optional(), device: z.boolean().optional(), diff --git a/cli/src/sdk.ts b/cli/src/sdk.ts index ad244d367f..947a22721a 100644 --- a/cli/src/sdk.ts +++ b/cli/src/sdk.ts @@ -44,6 +44,7 @@ import type { ZipBundleOptions, } from './schemas/sdk' import type { Organization } from './utils' +import { buildCliRequestHeaders } from './analytics/cli-headers' import { checkAppExistsAndHasPermissionOrgErr } from './api/app' import { getActiveAppVersions } from './api/versions' import { addAppInternal } from './app/add' @@ -74,7 +75,6 @@ import { deleteOrganizationInternal } from './organization/delete' import { listOrganizationsInternal } from './organization/list' import { setOrganizationInternal } from './organization/set' import { getUserIdInternal } from './user/account' -import { buildCliRequestHeaders } from './analytics/cli-headers' import { createSupabaseClient, findSavedKey, getConfig, getLocalConfig } from './utils' import { parseSecurityPolicyError } from './utils/security_policy_errors' import { normalizeAutoBumpInput } from './versionHelpers' @@ -873,6 +873,7 @@ export class CapgoSDK { android: options.android, selfAssign: options.selfAssign, disableAutoUpdate: options.disableAutoUpdate ?? undefined, + updatePackage: options.updatePackage, dev: options.dev, emulator: options.emulator, device: options.device, @@ -1209,7 +1210,7 @@ export class CapgoSDK { const response = await fetch(`${localConfig.hostApi}/private/stats`, { method: 'POST', -headers: buildCliRequestHeaders({ 'Content-Type': 'application/json', capgkey: apikey }), + headers: buildCliRequestHeaders({ 'Content-Type': 'application/json', 'capgkey': apikey }), body: JSON.stringify(query), }) diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index 2b26f41cf5..98f473549a 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -840,6 +840,7 @@ export type Database = { rollout_percentage_bps: number rollout_version: number | null rbac_id: string + update_package: Database["public"]["Enums"]["channel_update_package"] updated_at: string version: number | null } @@ -879,6 +880,7 @@ export type Database = { rollout_percentage_bps?: number rollout_version?: number | null rbac_id?: string + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -918,6 +920,7 @@ export type Database = { rollout_percentage_bps?: number rollout_version?: number | null rbac_id?: string + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -4775,6 +4778,12 @@ export type Database = { } Enums: { action_type: "mau" | "storage" | "bandwidth" | "build_time" + channel_update_package: + | "all" + | "zip" + | "delta" + | "zip_from_builtin" + | "delta_from_builtin" credit_metric_type: "mau" | "bandwidth" | "storage" | "build_time" credit_transaction_type: | "grant" @@ -5036,6 +5045,13 @@ export const Constants = { public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], + channel_update_package: [ + "all", + "zip", + "delta", + "zip_from_builtin", + "delta_from_builtin", + ], credit_metric_type: ["mau", "bandwidth", "storage", "build_time"], credit_transaction_type: [ "grant", diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 45eedfb8dd..08f31d9ee2 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -816,6 +816,25 @@ export function formatCapgoApiErrorBody(body: unknown): string { return [record.error, record.message, record.status].filter(Boolean).join(' | ') } +function messageAfterPrefix(text: string, prefix: string): string { + const index = text.indexOf(prefix) + if (index < 0) + return text + return text.slice(index + prefix.length).replace(/^:\s*/, '').split('\n')[0]!.trim() +} + +export async function channelUpdatePackageCliError(error: unknown): Promise { + const payload = await readCapgoCliApiErrorPayload(error) + if (payload?.error === 'channel_zip_required' || payload?.error === 'channel_delta_required') + return payload.message || payload.error + const text = error instanceof Error ? error.message : String(error ?? '') + if (text.includes('CHANNEL_ZIP_REQUIRED')) + return messageAfterPrefix(text, 'CHANNEL_ZIP_REQUIRED') + if (text.includes('CHANNEL_DELTA_REQUIRED')) + return messageAfterPrefix(text, 'CHANNEL_DELTA_REQUIRED') + return null +} + /** Capgo-managed Supabase hosts (cloud). Match hostname exactly. */ export function isCapgoManagedSupabaseHost(supaHost?: string): boolean { if (!supaHost) diff --git a/docs/pr/channel-update-package.webp b/docs/pr/channel-update-package.webp new file mode 100644 index 0000000000..d99bf08b7b Binary files /dev/null and b/docs/pr/channel-update-package.webp differ diff --git a/messages/en.context.json b/messages/en.context.json index cbabf70e51..4ad6063687 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -2594,6 +2594,15 @@ "update-delivery-samples": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-delivery-trend": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-delivery-trend-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-all": "Used in Capgo web console areas: pages/app. Role: dropdown option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta": "Used in Capgo web console areas: pages/app. Role: dropdown option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta-from-builtin": "Used in Capgo web console areas: pages/app. Role: dropdown option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-delta-required": "Used in Capgo web console areas: pages/app, components/tables. Role: error toast. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-help": "Used in Capgo web console areas: pages/app. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip": "Used in Capgo web console areas: pages/app. Role: dropdown option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip-from-builtin": "Used in Capgo web console areas: pages/app. Role: dropdown option label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "update-package-zip-required": "Used in Capgo web console areas: pages/app, components/tables. Role: error toast. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update-password-now": "Used in Capgo web console areas: components. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "update_statistics": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "updated-at": "Used in Capgo web console areas: components/tables, pages/app, pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 20c4d5400e..2929015b1d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2423,6 +2423,15 @@ "unsafe": "Unsafe", "update": "Update", "update-password-now": "Update Password Now", + "update-package": "Update package", + "update-package-all": "Zip and delta", + "update-package-delta": "Delta only", + "update-package-delta-from-builtin": "Delta only from builtin", + "update-package-delta-required": "This channel requires a delta package, but the bundle has no delta files. Upload with delta enabled, or switch the channel to zip only or zip and delta.", + "update-package-help": "Choose whether devices download a full zip, a delta of changed files, or both. Restrict zip-only or delta-only delivery to devices still on the store builtin version if needed.", + "update-package-zip": "Zip only", + "update-package-zip-from-builtin": "Zip only from builtin", + "update-package-zip-required": "This channel requires a zip package, but the bundle has no zip. Upload a full zip without --delta-only, or switch the channel to delta only or zip and delta.", "update-delivery-devices": "Devices measured", "update-delivery-latency": "Time to deliver an update", "update-delivery-latency-help": "Device-side download delivery latency percentiles from download start to download complete.", diff --git a/read_replicate/replicate_prepare.sh b/read_replicate/replicate_prepare.sh index 5275e7296c..bd39874a1a 100755 --- a/read_replicate/replicate_prepare.sh +++ b/read_replicate/replicate_prepare.sh @@ -172,7 +172,7 @@ perl -0777 -i -pe ' printf 'DROP TABLE IF EXISTS public.channel_devices, public.manifest, public.onboarding_demo_data, public.app_versions, public.channels, public.apps, public.notifications, public.org_users, public.orgs, public.stripe_info CASCADE;\n' printf 'DROP SEQUENCE IF EXISTS public.app_versions_id_seq, public.channel_devices_id_seq, public.channel_id_seq, public.manifest_id_seq, public.org_users_id_seq, public.stripe_info_id_seq CASCADE;\n' printf 'DROP FUNCTION IF EXISTS public.one_month_ahead();\n' - printf 'DROP TYPE IF EXISTS public.manifest_entry, public.disable_update, public.stripe_status;\n\n' + printf 'DROP TYPE IF EXISTS public.channel_update_package, public.manifest_entry, public.disable_update, public.stripe_status;\n\n' cat "$OUT_SQL" printf '\nCOMMIT;\n' } > "${OUT_SQL}.tmp" diff --git a/read_replicate/schema_catalog.ts b/read_replicate/schema_catalog.ts index b2da84d5b8..d1030166ff 100644 --- a/read_replicate/schema_catalog.ts +++ b/read_replicate/schema_catalog.ts @@ -14,6 +14,7 @@ export const REPLICA_TABLES = [ ] as const export const REPLICA_TYPES = [ + 'channel_update_package', 'disable_update', 'manifest_entry', 'stripe_status', diff --git a/read_replicate/schema_replicate.catalog.json b/read_replicate/schema_replicate.catalog.json index f7b40ad9f5..b9e0a8972b 100644 --- a/read_replicate/schema_replicate.catalog.json +++ b/read_replicate/schema_replicate.catalog.json @@ -970,6 +970,16 @@ "table": "channels", "type": "timestamp with time zone" }, + { + "default": "'all'::channel_update_package", + "generated": "", + "identity": "", + "name": "update_package", + "notNull": true, + "position": 38, + "table": "channels", + "type": "channel_update_package" + }, { "default": "nextval('manifest_id_seq'::regclass)", "generated": "", @@ -2613,6 +2623,17 @@ } ], "types": [ + { + "definition": [ + "all", + "zip", + "delta", + "zip_from_builtin", + "delta_from_builtin" + ], + "kind": "e", + "name": "channel_update_package" + }, { "definition": [ "major", diff --git a/read_replicate/schema_replicate.sql b/read_replicate/schema_replicate.sql index ec28434c82..a62c55a22f 100644 --- a/read_replicate/schema_replicate.sql +++ b/read_replicate/schema_replicate.sql @@ -3,12 +3,25 @@ BEGIN; DROP TABLE IF EXISTS public.channel_devices, public.manifest, public.onboarding_demo_data, public.app_versions, public.channels, public.apps, public.notifications, public.org_users, public.orgs, public.stripe_info CASCADE; DROP SEQUENCE IF EXISTS public.app_versions_id_seq, public.channel_devices_id_seq, public.channel_id_seq, public.manifest_id_seq, public.org_users_id_seq, public.stripe_info_id_seq CASCADE; DROP FUNCTION IF EXISTS public.one_month_ahead(); -DROP TYPE IF EXISTS public.manifest_entry, public.disable_update, public.stripe_status; +DROP TYPE IF EXISTS public.channel_update_package, public.manifest_entry, public.disable_update, public.stripe_status; -- -- +-- +-- Name: channel_update_package; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.channel_update_package AS ENUM ( + 'all', + 'zip', + 'delta', + 'zip_from_builtin', + 'delta_from_builtin' +); + + -- -- Name: disable_update; Type: TYPE; Schema: public; Owner: - -- @@ -231,6 +244,7 @@ CREATE TABLE public.channels ( auto_pause_cooldown_minutes integer DEFAULT 60 NOT NULL, auto_pause_last_triggered_at timestamp with time zone, auto_pause_last_checked_at timestamp with time zone, + update_package public.channel_update_package DEFAULT 'all'::public.channel_update_package NOT NULL, CONSTRAINT channels_auto_pause_action_check CHECK ((auto_pause_action = ANY (ARRAY['pause'::text, 'rollback'::text, 'notify'::text]))), CONSTRAINT channels_auto_pause_confidence_check CHECK (((auto_pause_confidence > (0)::numeric) AND (auto_pause_confidence < (1)::numeric))), CONSTRAINT channels_auto_pause_cooldown_minutes_check CHECK (((auto_pause_cooldown_minutes >= 0) AND (auto_pause_cooldown_minutes <= 10080))), diff --git a/src/components/tables/ChannelHistoryTable.vue b/src/components/tables/ChannelHistoryTable.vue index 29ec8a92e2..729fc3f87d 100644 --- a/src/components/tables/ChannelHistoryTable.vue +++ b/src/components/tables/ChannelHistoryTable.vue @@ -4,6 +4,7 @@ import type { TableColumn, TableSort } from '~/components/comp_def' import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' +import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' @@ -70,6 +71,7 @@ const fieldLabels: Record = { allow_device_self_set: 'channel-allow-device-self-set', disable_auto_update: 'channel-disable-auto-update', disable_auto_update_under_native: 'channel-disable-auto-update-under-native', + update_package: 'update-package', } function getFieldLabel(field: string): string { @@ -84,6 +86,20 @@ function formatValue(field: string, value: unknown): string { return value ? t('yes') : t('no') if (field === 'version' && typeof value === 'number') return `#${value}` + if (field === 'update_package') { + switch (value) { + case 'zip': + return t('update-package-zip') + case 'delta': + return t('update-package-delta') + case 'zip_from_builtin': + return t('update-package-zip-from-builtin') + case 'delta_from_builtin': + return t('update-package-delta-from-builtin') + default: + return t('update-package-all') + } + } return String(value) } @@ -385,7 +401,7 @@ async function handleRollback(item: HistoryEntry) { if (error) { console.error('Error rolling back version:', error) - toast.error(t('error-rollback')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'error-rollback')) return } @@ -395,7 +411,7 @@ async function handleRollback(item: HistoryEntry) { } catch (error) { console.error('Error rolling back version:', error) - toast.error(t('error-rollback')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'error-rollback')) } }, }, diff --git a/src/components/tables/HistoryTable.vue b/src/components/tables/HistoryTable.vue index e8f7b1a71c..176475f869 100644 --- a/src/components/tables/HistoryTable.vue +++ b/src/components/tables/HistoryTable.vue @@ -5,6 +5,7 @@ import { computedAsync } from '@vueuse/core' import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' +import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' @@ -454,7 +455,7 @@ async function handleRollback(item: DeployHistory) { if (error) { console.error('Error rolling back version:', error) - toast.error(t('error-rollback')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'error-rollback')) return } @@ -464,7 +465,7 @@ async function handleRollback(item: DeployHistory) { } catch (error) { console.error('Error rolling back version:', error) - toast.error(t('error-rollback')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'error-rollback')) } }, }, diff --git a/src/pages/app/[app].bundle.[bundle].vue b/src/pages/app/[app].bundle.[bundle].vue index 493f140377..9115c6bccd 100644 --- a/src/pages/app/[app].bundle.[bundle].vue +++ b/src/pages/app/[app].bundle.[bundle].vue @@ -19,6 +19,7 @@ import IconAlertCircle from '~icons/lucide/alert-circle' import IconPencil from '~icons/lucide/pencil' import { fetchLinkedChannelsForVersion, formatLinkedChannel, unlinkLinkedChannels } from '~/services/bundleLinkedChannels' import { findChannelsWithoutPromotionPermission, formatChannelPromotionTargets } from '~/services/channelPromotion' +import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatBytes, getChecksumInfo } from '~/services/conversion' import { formatDate, formatLocalDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' @@ -341,7 +342,7 @@ async function handleChannelLink(chan: Database['public']['Tables']['channels'][ } catch (error) { console.error(error) - toast.error(t('cannot-test-app-some')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'cannot-test-app-some')) } } diff --git a/src/pages/app/[app].channel.[channel].vue b/src/pages/app/[app].channel.[channel].vue index f0704d5704..d48bc31713 100644 --- a/src/pages/app/[app].channel.[channel].vue +++ b/src/pages/app/[app].channel.[channel].vue @@ -16,6 +16,7 @@ import IconAlertCircle from '~icons/lucide/alert-circle' import IconWarning from '~icons/lucide/alert-triangle' import IconExternalLink from '~icons/lucide/external-link' import IconDown from '~icons/material-symbols/keyboard-arrow-down-rounded' +import { channelUpdatePackageErrorKey } from '~/services/channelUpdatePackageError' import { formatDate, formatLocalDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { checkCompatibilityNativePackages, defaultApiHost, isCompatible, useSupabase } from '~/services/supabase' @@ -43,6 +44,7 @@ type EditableChannelKey = 'allow_dev' | 'disable_auto_update_under_native' | 'electron' | 'ios' + | 'update_package' | 'rollout_cache_ttl_seconds' | 'rollout_enabled' | 'rollout_paused_at' @@ -136,6 +138,8 @@ const showDebugSection = ref(false) // Auto update dropdown state const autoUpdateDropdown = useTemplateRef('autoUpdateDropdown') onClickOutside(autoUpdateDropdown, () => closeAutoUpdateDropdown()) +const updatePackageDropdown = useTemplateRef('updatePackageDropdown') +onClickOutside(updatePackageDropdown, () => closeUpdatePackageDropdown()) function openBundle() { if (!channel.value || channel.value.version.storage_provider === 'revert_to_builtin') @@ -205,6 +209,7 @@ async function getChannel(force = false) { allow_device_self_set, disable_auto_update_under_native, disable_auto_update, + update_package, ios, android, electron, @@ -264,7 +269,7 @@ async function saveChannelChanges(update: ChannelUpdate) { .update(update) .eq('id', id.value) if (error) { - toast.error(t('error-update-channel')) + toast.error(t(channelUpdatePackageErrorKey(error) ?? 'error-update-channel')) console.error('no channel update', error) return false } @@ -722,6 +727,12 @@ function closeAutoUpdateDropdown() { } } +function closeUpdatePackageDropdown() { + if (updatePackageDropdown.value) { + updatePackageDropdown.value.removeAttribute('open') + } +} + function getAutoUpdateLabel(value: string) { switch (value) { case 'major': @@ -768,6 +779,39 @@ async function onSelectAutoUpdate(value: Database['public']['Enums']['disable_up closeAutoUpdateDropdown() } +const updatePackageOptions = [ + 'all', + 'zip', + 'delta', + 'zip_from_builtin', + 'delta_from_builtin', +] as const satisfies Database['public']['Enums']['channel_update_package'][] + +function getUpdatePackageLabel(value?: Database['public']['Enums']['channel_update_package'] | null) { + switch (value) { + case 'zip': + return t('update-package-zip') + case 'delta': + return t('update-package-delta') + case 'zip_from_builtin': + return t('update-package-zip-from-builtin') + case 'delta_from_builtin': + return t('update-package-delta-from-builtin') + default: + return t('update-package-all') + } +} + +async function onSelectUpdatePackage(value: Database['public']['Enums']['channel_update_package']) { + if (!canUpdateChannelSettings.value) { + toast.error(t('no-permission')) + return false + } + + await saveChannelChange('update_package', value) + closeUpdatePackageDropdown() +} + function openLink(url?: string): void { if (url) { const win = window.open(url, '_blank') @@ -1304,6 +1348,38 @@ async function copyCurlCommand() { + +
+
+ + {{ getUpdatePackageLabel(channel.update_package) }} + + +
    +
  • + +
  • +
+
+
+ +
+ {{ t('update-package-help') }} +
+
+
+
+ typeof value === 'string' && value.length > 0) + if (parts.length) + return parts.join('\n') + } + return String(error) +} + +export function channelUpdatePackageErrorKey(error: unknown): ChannelUpdatePackageErrorKey | null { + const message = readErrorText(error) + if (message.includes('CHANNEL_ZIP_REQUIRED') || message.includes('channel_zip_required')) + return 'update-package-zip-required' + if (message.includes('CHANNEL_DELTA_REQUIRED') || message.includes('channel_delta_required')) + return 'update-package-delta-required' + return null +} diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index 21c2191dfd..2258504636 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -870,6 +870,7 @@ export type Database = { rollout_paused_at: string | null rollout_percentage_bps: number rollout_version: number | null + update_package: Database["public"]["Enums"]["channel_update_package"] updated_at: string version: number | null } @@ -909,6 +910,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -948,6 +950,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -5402,6 +5405,12 @@ export type Database = { } Enums: { action_type: "mau" | "storage" | "bandwidth" | "build_time" + channel_update_package: + | "all" + | "zip" + | "delta" + | "zip_from_builtin" + | "delta_from_builtin" credit_metric_type: "mau" | "bandwidth" | "storage" | "build_time" credit_transaction_type: | "grant" @@ -5674,6 +5683,13 @@ export const Constants = { public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], + channel_update_package: [ + "all", + "zip", + "delta", + "zip_from_builtin", + "delta_from_builtin", + ], credit_metric_type: ["mau", "bandwidth", "storage", "build_time"], credit_transaction_type: [ "grant", diff --git a/supabase/functions/_backend/plugin_runtime/utils/pg.ts b/supabase/functions/_backend/plugin_runtime/utils/pg.ts index 1fe3e9ca73..85e86fcd8c 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/pg.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/pg.ts @@ -836,6 +836,7 @@ function getSchemaUpdatesAlias(includeMetadata = false) { allow_device: channelAlias.allow_device, disable_auto_update_under_native: channelAlias.disable_auto_update_under_native, disable_auto_update: channelAlias.disable_auto_update, + update_package: channelAlias.update_package, ios: channelAlias.ios, android: channelAlias.android, electron: channelAlias.electron, diff --git a/supabase/functions/_backend/plugin_runtime/utils/postgres_schema.ts b/supabase/functions/_backend/plugin_runtime/utils/postgres_schema.ts index d094df6bfe..1650bca52a 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/postgres_schema.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/postgres_schema.ts @@ -3,6 +3,7 @@ import { bigint, boolean, integer, jsonb, numeric, pgEnum, pgTable, primaryKey, // do_not_change export const disableUpdatePgEnum = pgEnum('disable_update', ['major', 'minor', 'patch', 'version_number', 'none']) +export const channelUpdatePackagePgEnum = pgEnum('channel_update_package', ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin']) export const apps = pgTable('apps', { created_at: timestamp('created_at').notNull().defaultNow(), @@ -75,6 +76,7 @@ export const channels = pgTable('channels', { public: boolean('public').notNull().default(false), disable_auto_update_under_native: boolean('disable_auto_update_under_native').notNull().default(true), disable_auto_update: disableUpdatePgEnum('disable_auto_update').default('major').notNull(), + update_package: channelUpdatePackagePgEnum('update_package').default('all').notNull(), ios: boolean('ios').default(true).notNull(), android: boolean('android').notNull().default(true), electron: boolean('electron').notNull().default(true), diff --git a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts index b814a5b1e1..c7396492ab 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/supabase.types.ts @@ -840,6 +840,7 @@ export type Database = { rollout_paused_at: string | null rollout_percentage_bps: number rollout_version: number | null + update_package: Database["public"]["Enums"]["channel_update_package"] updated_at: string version: number | null } @@ -879,6 +880,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -918,6 +920,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -5189,6 +5192,12 @@ export type Database = { } Enums: { action_type: "mau" | "storage" | "bandwidth" | "build_time" + channel_update_package: + | "all" + | "zip" + | "delta" + | "zip_from_builtin" + | "delta_from_builtin" credit_metric_type: "mau" | "bandwidth" | "storage" | "build_time" credit_transaction_type: | "grant" @@ -5461,6 +5470,13 @@ export const Constants = { public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], + channel_update_package: [ + "all", + "zip", + "delta", + "zip_from_builtin", + "delta_from_builtin", + ], credit_metric_type: ["mau", "bandwidth", "storage", "build_time"], credit_transaction_type: [ "grant", diff --git a/supabase/functions/_backend/plugin_runtime/utils/update.ts b/supabase/functions/_backend/plugin_runtime/utils/update.ts index b7c2af0e17..a74f148e90 100644 --- a/supabase/functions/_backend/plugin_runtime/utils/update.ts +++ b/supabase/functions/_backend/plugin_runtime/utils/update.ts @@ -234,6 +234,27 @@ function getResponseFeatureSupport(plugin_version: string): ResponseFeatureSuppo return support } +export const CHANNEL_UPDATE_PACKAGE_VALUES = ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin'] as const +export type ChannelUpdatePackage = typeof CHANNEL_UPDATE_PACKAGE_VALUES[number] +export type ResolvedChannelUpdatePackage = 'all' | 'zip' | 'delta' + +export function isOnBuiltinVersion(versionName: string, versionBuild: string) { + return versionName === 'builtin' || versionName === versionBuild +} + +export function resolveChannelUpdatePackage( + mode: ChannelUpdatePackage | null | undefined, + isOnBuiltin: boolean, +): ResolvedChannelUpdatePackage { + if (mode === 'zip' || mode === 'delta') + return mode + if (isOnBuiltin && mode === 'zip_from_builtin') + return 'zip' + if (isOnBuiltin && mode === 'delta_from_builtin') + return 'delta' + return 'all' +} + export function resToVersion(plugin_version: string, signedURL: string, version: Database['public']['Tables']['app_versions']['Row'], manifest: ManifestEntry[], expose_metadata: boolean = false) { const support = getResponseFeatureSupport(plugin_version) const res: { @@ -547,12 +568,22 @@ export async function updateWithPG( const version = channelOverride?.version ?? channelData.version let manifestEntries = (channelOverride?.manifestEntries ?? channelData?.manifestEntries ?? []) as Partial[] + const updatePackage = resolveChannelUpdatePackage( + channelData.channels.update_package, + isOnBuiltinVersion(version_name, version_build), + ) + const pluginSupportsManifest = getResponseFeatureSupport(plugin_version).manifest + // External URLs and old plugins cannot consume delta manifests — keep a zip. + const serveZip = updatePackage !== 'delta' || !pluginSupportsManifest || Boolean(version.external_url) + const serveDelta = updatePackage !== 'zip' && pluginSupportsManifest && !version.external_url // device.version = versionData ? versionData.id : version.id // App-level manifest_bundle_count gates whether we may load files later. // Do not require version.manifest_count here — seed/legacy rows can lag that column // while still having manifest table entries (and the old json_agg path found them). - if (!version.external_url && !version.r2_path && !isInternalVersionName(version.name) && !fetchManifestEntries && (!manifestEntries || manifestEntries.length === 0)) { + const hasZip = Boolean(version.external_url || version.r2_path) + const canServeDelta = serveDelta && (fetchManifestEntries || (manifestEntries && manifestEntries.length > 0)) + if (!isInternalVersionName(version.name) && !hasZip && !canServeDelta) { cloudlog({ requestId: c.get('requestId'), message: 'Cannot get bundle', id: app_id, version, manifestEntriesLength: manifestEntries ? manifestEntries.length : 0, channelData: channelData ? channelData.channels.name : 'no channel data', defaultChannel }) await sendStatsAndDevice(c, device, [{ action: 'missingBundle', versionName: version.name }]) return updateError200(c, 'no_bundle', 'Cannot get bundle') @@ -590,7 +621,8 @@ export async function updateWithPG( // Manifest is loaded later (after gates) via indexed app_version_id — never // channel-query json_agg (that was the P999 tax on up-to-date). - const needsDeferredManifest = !version.external_url + const needsDeferredManifest = serveDelta + && !version.external_url && fetchManifestEntries && (!manifestEntries || manifestEntries.length === 0) @@ -770,7 +802,7 @@ export async function updateWithPG( return updateError200(c, 'revert_to_builtin_plugin_version_too_old', 'revert_to_builtin used, but plugin version is too old') } const startBundleUrl = performance.now() - let signedURL = version.external_url ?? '' + let signedURL = serveZip ? (version.external_url ?? '') : '' let manifest: ManifestEntry[] = [] let manifestFetchMs = 0 if (!version.external_url) { @@ -786,7 +818,7 @@ export async function updateWithPG( }) : null const [url, deferredEntries] = await Promise.all([ - version.r2_path + serveZip && version.r2_path ? getBundleUrl(c, version.r2_path, device_id, version.checksum ?? '') : Promise.resolve(null), deferredManifestPromise ?? Promise.resolve(null), @@ -803,12 +835,13 @@ export async function updateWithPG( }) } } - if (!version.r2_path && !isInternalVersionName(version.name) && (!manifestEntries || manifestEntries.length === 0)) { + if (!version.r2_path && !isInternalVersionName(version.name) && !(serveDelta && manifestEntries && manifestEntries.length > 0)) { cloudlog({ requestId: c.get('requestId'), message: 'Cannot get bundle', id: app_id, version, manifestEntriesLength: 0, channelData: channelData ? channelData.channels.name : 'no channel data', defaultChannel }) await sendStatsAndDevice(c, device, [{ action: 'missingBundle', versionName: version.name }]) return updateError200(c, 'no_bundle', 'Cannot get bundle') } - manifest = getManifestUrl(c, version.id, manifestEntries, device_id) + if (serveDelta) + manifest = getManifestUrl(c, version.id, manifestEntries, device_id) } const endBundleUrl = performance.now() const bundleUrlMs = Math.round(endBundleUrl - startBundleUrl) diff --git a/supabase/functions/_backend/public/bundle/set_channel.ts b/supabase/functions/_backend/public/bundle/set_channel.ts index 0cb38bc5df..cfa36f4e11 100644 --- a/supabase/functions/_backend/public/bundle/set_channel.ts +++ b/supabase/functions/_backend/public/bundle/set_channel.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' +import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { simpleError } from '../../utils/hono.ts' import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' import { checkPermissionPg } from '../../utils/rbac.ts' @@ -166,6 +167,7 @@ export async function setChannel(c: Context, body: SetCh } if (error instanceof HTTPException) throw error + throwIfChannelUpdatePackageMismatch(error) logPgError(c, 'set_channel_update', error) throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: (error as Error)?.message }) } diff --git a/supabase/functions/_backend/public/channel/get.ts b/supabase/functions/_backend/public/channel/get.ts index b49cc408a6..807ff3a6a2 100644 --- a/supabase/functions/_backend/public/channel/get.ts +++ b/supabase/functions/_backend/public/channel/get.ts @@ -28,6 +28,7 @@ async function getAll(c: Context, body: GetDevice, apikey: Database['public']['T public, disable_auto_update_under_native, disable_auto_update, + update_package, allow_device_self_set, allow_emulator, allow_device, @@ -67,11 +68,12 @@ async function getAll(c: Context, body: GetDevice, apikey: Database['public']['T throw simpleError('cannot_find_channels', 'Cannot find channels', { supabaseError: dbError }) } return c.json(dataChannels.map((o) => { - const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = o + const { disable_auto_update_under_native, disable_auto_update, update_package, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = o return { ...rest, disableAutoUpdateUnderNative: disable_auto_update_under_native, disableAutoUpdate: disable_auto_update, + updatePackage: update_package, rolloutPercentageBps: rollout_percentage_bps, rolloutEnabled: rollout_enabled, rolloutPausedAt: rollout_paused_at, @@ -104,6 +106,7 @@ async function getOne(c: Context, body: GetDevice, apikey: Database['public']['T public, disable_auto_update_under_native, disable_auto_update, + update_package, allow_device_self_set, allow_emulator, allow_device, @@ -144,11 +147,12 @@ async function getOne(c: Context, body: GetDevice, apikey: Database['public']['T throw simpleError('cannot_find_version', 'Cannot find version', { supabaseError: dbError }) } - const { disable_auto_update_under_native, disable_auto_update, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = dataChannel + const { disable_auto_update_under_native, disable_auto_update, update_package, rollout_percentage_bps, rollout_enabled, rollout_paused_at, rollout_pause_reason, rollout_cache_ttl_seconds, auto_pause_enabled, auto_pause_window_minutes, auto_pause_failure_rate_bps, auto_pause_confidence, auto_pause_min_attempts, auto_pause_min_failures, auto_pause_action, auto_pause_cooldown_minutes, auto_pause_last_triggered_at, auto_pause_last_checked_at, ...rest } = dataChannel const newObject = { ...rest, disableAutoUpdateUnderNative: disable_auto_update_under_native, disableAutoUpdate: disable_auto_update, + updatePackage: update_package, rolloutPercentageBps: rollout_percentage_bps, rolloutEnabled: rollout_enabled, rolloutPausedAt: rollout_paused_at, diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index 86d356a9b8..250240f3d6 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -2,6 +2,7 @@ import type { Context } from 'hono' import type { MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' import { HTTPException } from 'hono/http-exception' +import { throwIfChannelUpdatePackageMismatch } from '../../utils/channel_update_package.ts' import { BRES, simpleError } from '../../utils/hono.ts' import { cloudlogErr } from '../../utils/logging.ts' import { closeClient, getDrizzleClient, getPgClient, logPgError } from '../../utils/pg.ts' @@ -17,6 +18,8 @@ interface ChannelSet { public?: boolean disableAutoUpdateUnderNative?: boolean disableAutoUpdate?: Database['public']['Enums']['disable_update'] + updatePackage?: Database['public']['Enums']['channel_update_package'] + update_package?: Database['public']['Enums']['channel_update_package'] ios?: boolean android?: boolean electron?: boolean @@ -86,6 +89,7 @@ function normalizeChannelSet(body: ChannelSet): ChannelSet { autoPauseMinFailures: definedOrAlias(body.autoPauseMinFailures, body.auto_pause_min_failures), autoPauseAction: definedOrAlias(body.autoPauseAction, body.auto_pause_action), autoPauseCooldownMinutes: definedOrAlias(body.autoPauseCooldownMinutes, body.auto_pause_cooldown_minutes), + updatePackage: definedOrAlias(body.updatePackage, body.update_package), } } @@ -154,6 +158,7 @@ const channelInsertColumns = [ 'public', 'disable_auto_update_under_native', 'disable_auto_update', + 'update_package', 'ios', 'android', 'electron', @@ -198,6 +203,7 @@ async function insertChannelInTransaction( public: channel.public, disable_auto_update_under_native: channel.disable_auto_update_under_native, disable_auto_update: channel.disable_auto_update, + update_package: channel.update_package, ios: channel.ios, android: channel.android, electron: channel.electron, @@ -312,6 +318,7 @@ async function createAndPromoteChannelInTransaction( } if (error instanceof HTTPException) throw error + throwIfChannelUpdatePackageMismatch(error) logPgError(c, 'create_and_promote_channel', error) throw simpleError('cannot_set_bundle_to_channel', 'Cannot set bundle to channel', { error: (error as Error)?.message }) } @@ -398,6 +405,10 @@ export async function post(c: Context, body: ChannelSet, if (body.autoPauseAction && !['pause', 'rollback', 'notify'].includes(body.autoPauseAction)) { throw simpleError('invalid_auto_pause_action', 'Auto-pause action must be pause, rollback, or notify', { autoPauseAction: body.autoPauseAction }) } + const updatePackages = ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin'] as const + if (body.updatePackage != null && !updatePackages.includes(body.updatePackage)) { + throw simpleError('invalid_update_package', 'Update package must be all, zip, delta, zip_from_builtin, or delta_from_builtin', { updatePackage: body.updatePackage }) + } const disablesRollout = body.rolloutEnabled === false && !body.rollback && !body.promoteToStable // Clearing rollout_version (disable unlink / explicit target / rollback / promote) is gated by the DB trigger with channel.promote_bundle. const changesRolloutTarget = body.rolloutVersion !== undefined @@ -428,6 +439,7 @@ export async function post(c: Context, body: ChannelSet, ...(body.public == null ? {} : { public: body.public }), ...(body.disableAutoUpdateUnderNative == null ? {} : { disable_auto_update_under_native: body.disableAutoUpdateUnderNative }), ...(body.disableAutoUpdate == null ? {} : { disable_auto_update: body.disableAutoUpdate }), + ...(body.updatePackage == null ? {} : { update_package: body.updatePackage }), ...(body.allow_device_self_set == null ? {} : { allow_device_self_set: body.allow_device_self_set }), ...(body.allow_emulator == null ? {} : { allow_emulator: body.allow_emulator }), ...(body.allow_device == null ? {} : { allow_device: body.allow_device }), @@ -496,6 +508,12 @@ export async function post(c: Context, body: ChannelSet, return c.json({ ...BRES, ...createdChannel }) } - await updateOrCreateChannel(c, channel, existingChannelId, body.version === undefined && !body.promoteToStable) + try { + await updateOrCreateChannel(c, channel, existingChannelId, body.version === undefined && !body.promoteToStable) + } + catch (error) { + throwIfChannelUpdatePackageMismatch(error) + throw error + } return c.json(BRES) } diff --git a/supabase/functions/_backend/utils/channel_update_package.ts b/supabase/functions/_backend/utils/channel_update_package.ts new file mode 100644 index 0000000000..57303eee87 --- /dev/null +++ b/supabase/functions/_backend/utils/channel_update_package.ts @@ -0,0 +1,46 @@ +import { simpleError } from './hono.ts' + +const ZIP_REQUIRED = 'CHANNEL_ZIP_REQUIRED' +const DELTA_REQUIRED = 'CHANNEL_DELTA_REQUIRED' + +function readErrorText(error: unknown): string { + if (!error) + return '' + if (typeof error === 'string') + return error + if (error instanceof Error) { + const extras = [ + error.message, + (error as { code?: unknown }).code, + (error as { details?: unknown }).details, + (error as { cause?: { message?: unknown } }).cause?.message, + ].filter(value => typeof value === 'string') + return extras.join('\n') + } + if (typeof error === 'object' && 'message' in error) + return String((error as { message?: unknown }).message ?? '') + return String(error) +} + +function messageAfterPrefix(text: string, prefix: string): string { + const index = text.indexOf(prefix) + if (index < 0) + return text + return text.slice(index + prefix.length).replace(/^:\s*/, '').split('\n')[0]!.trim() +} + +export function throwIfChannelUpdatePackageMismatch(error: unknown): void { + const text = readErrorText(error) + if (text.includes(ZIP_REQUIRED)) { + throw simpleError( + 'channel_zip_required', + messageAfterPrefix(text, ZIP_REQUIRED) || 'This channel requires a zip package, but the bundle has no zip.', + ) + } + if (text.includes(DELTA_REQUIRED)) { + throw simpleError( + 'channel_delta_required', + messageAfterPrefix(text, DELTA_REQUIRED) || 'This channel requires a delta package, but the bundle has no delta files.', + ) + } +} diff --git a/supabase/functions/_backend/utils/pg.ts b/supabase/functions/_backend/utils/pg.ts index 119faa457b..2f5a19f3e1 100644 --- a/supabase/functions/_backend/utils/pg.ts +++ b/supabase/functions/_backend/utils/pg.ts @@ -505,6 +505,7 @@ function getSchemaUpdatesAlias(includeMetadata = false) { allow_device: channelAlias.allow_device, disable_auto_update_under_native: channelAlias.disable_auto_update_under_native, disable_auto_update: channelAlias.disable_auto_update, + update_package: channelAlias.update_package, ios: channelAlias.ios, android: channelAlias.android, electron: channelAlias.electron, diff --git a/supabase/functions/_backend/utils/postgres_schema.ts b/supabase/functions/_backend/utils/postgres_schema.ts index d094df6bfe..1650bca52a 100644 --- a/supabase/functions/_backend/utils/postgres_schema.ts +++ b/supabase/functions/_backend/utils/postgres_schema.ts @@ -3,6 +3,7 @@ import { bigint, boolean, integer, jsonb, numeric, pgEnum, pgTable, primaryKey, // do_not_change export const disableUpdatePgEnum = pgEnum('disable_update', ['major', 'minor', 'patch', 'version_number', 'none']) +export const channelUpdatePackagePgEnum = pgEnum('channel_update_package', ['all', 'zip', 'delta', 'zip_from_builtin', 'delta_from_builtin']) export const apps = pgTable('apps', { created_at: timestamp('created_at').notNull().defaultNow(), @@ -75,6 +76,7 @@ export const channels = pgTable('channels', { public: boolean('public').notNull().default(false), disable_auto_update_under_native: boolean('disable_auto_update_under_native').notNull().default(true), disable_auto_update: disableUpdatePgEnum('disable_auto_update').default('major').notNull(), + update_package: channelUpdatePackagePgEnum('update_package').default('all').notNull(), ios: boolean('ios').default(true).notNull(), android: boolean('android').notNull().default(true), electron: boolean('electron').notNull().default(true), diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 21c2191dfd..2258504636 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -870,6 +870,7 @@ export type Database = { rollout_paused_at: string | null rollout_percentage_bps: number rollout_version: number | null + update_package: Database["public"]["Enums"]["channel_update_package"] updated_at: string version: number | null } @@ -909,6 +910,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -948,6 +950,7 @@ export type Database = { rollout_paused_at?: string | null rollout_percentage_bps?: number rollout_version?: number | null + update_package?: Database["public"]["Enums"]["channel_update_package"] updated_at?: string version?: number | null } @@ -5402,6 +5405,12 @@ export type Database = { } Enums: { action_type: "mau" | "storage" | "bandwidth" | "build_time" + channel_update_package: + | "all" + | "zip" + | "delta" + | "zip_from_builtin" + | "delta_from_builtin" credit_metric_type: "mau" | "bandwidth" | "storage" | "build_time" credit_transaction_type: | "grant" @@ -5674,6 +5683,13 @@ export const Constants = { public: { Enums: { action_type: ["mau", "storage", "bandwidth", "build_time"], + channel_update_package: [ + "all", + "zip", + "delta", + "zip_from_builtin", + "delta_from_builtin", + ], credit_metric_type: ["mau", "bandwidth", "storage", "build_time"], credit_transaction_type: [ "grant", diff --git a/supabase/migrations/20260814134643_channel_update_package.sql b/supabase/migrations/20260814134643_channel_update_package.sql new file mode 100644 index 0000000000..2a80ee2b13 --- /dev/null +++ b/supabase/migrations/20260814134643_channel_update_package.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."channel_update_package" AS ENUM ( + 'all', + 'zip', + 'delta', + 'zip_from_builtin', + 'delta_from_builtin' +); + +ALTER TYPE "public"."channel_update_package" OWNER TO "postgres"; + +ALTER TABLE "public"."channels" +ADD COLUMN "update_package" "public"."channel_update_package" DEFAULT 'all'::"public"."channel_update_package" NOT NULL; + +COMMENT ON COLUMN "public"."channels"."update_package" IS + 'How /updates serves the channel bundle: all (zip+delta), zip, delta, or zip/delta only when the device is still on the store builtin version.'; diff --git a/supabase/migrations/20260814161627_channel_update_package_bundle_guard.sql b/supabase/migrations/20260814161627_channel_update_package_bundle_guard.sql new file mode 100644 index 0000000000..35926178cf --- /dev/null +++ b/supabase/migrations/20260814161627_channel_update_package_bundle_guard.sql @@ -0,0 +1,138 @@ +-- Refuse zip-only / delta-only channel settings when the linked bundle cannot +-- serve that package. Runs once per channel row write (not a hot path). +-- Lookups: app_versions by PK, then optional EXISTS on indexed manifest.app_version_id. + +CREATE OR REPLACE FUNCTION public.channel_update_package_mismatch( + p_update_package public.channel_update_package, + p_version_id bigint, + p_channel_name text +) +RETURNS text +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_name text; + v_storage_provider text; + v_r2_path text; + v_external_url text; + v_legacy_manifest public.manifest_entry[]; + v_has_zip boolean; + v_has_delta boolean; +BEGIN + IF p_version_id IS NULL OR p_update_package IS NULL OR p_update_package = 'all' THEN + RETURN NULL; + END IF; + + SELECT + app_version.name, + app_version.storage_provider, + app_version.r2_path, + app_version.external_url, + app_version.manifest + INTO + v_name, + v_storage_provider, + v_r2_path, + v_external_url, + v_legacy_manifest + FROM public.app_versions AS app_version + WHERE app_version.id = p_version_id; + + IF NOT FOUND THEN + RETURN NULL; + END IF; + + IF v_name IN ('builtin', 'unknown') THEN + RETURN NULL; + END IF; + + v_has_zip := ( + (v_storage_provider = 'external' AND NULLIF(BTRIM(COALESCE(v_external_url, '')), '') IS NOT NULL) + OR ( + v_storage_provider IS DISTINCT FROM 'r2-direct' + AND NULLIF(BTRIM(COALESCE(v_r2_path, '')), '') IS NOT NULL + ) + ); + + v_has_delta := COALESCE(pg_catalog.array_length(v_legacy_manifest, 1), 0) > 0 + OR EXISTS ( + SELECT 1 + FROM public.manifest AS manifest_row + WHERE manifest_row.app_version_id = p_version_id + ); + + IF p_update_package IN ('zip', 'zip_from_builtin') AND NOT v_has_zip THEN + RETURN format( + 'CHANNEL_ZIP_REQUIRED: Channel "%s" requires a zip package, but bundle "%s" has no zip. Upload a full zip (omit --delta-only) or set the channel to delta only / zip and delta.', + p_channel_name, + v_name + ); + END IF; + + IF p_update_package IN ('delta', 'delta_from_builtin') AND NOT v_has_delta THEN + RETURN format( + 'CHANNEL_DELTA_REQUIRED: Channel "%s" requires a delta package, but bundle "%s" has no delta files. Upload with delta enabled (`npx @capgo/cli@latest bundle upload --delta`) or set the channel to zip only / zip and delta.', + p_channel_name, + v_name + ); + END IF; + + RETURN NULL; +END; +$$; + +ALTER FUNCTION public.channel_update_package_mismatch(public.channel_update_package, bigint, text) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.channel_update_package_mismatch(public.channel_update_package, bigint, text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.channel_update_package_mismatch(public.channel_update_package, bigint, text) TO service_role; + +COMMENT ON FUNCTION public.channel_update_package_mismatch(public.channel_update_package, bigint, text) IS + 'Returns a user-facing error when a channel package mode cannot be served by the given bundle. NULL means compatible. Internal builtin/unknown versions are skipped.'; + +CREATE OR REPLACE FUNCTION public.enforce_channel_update_package_bundle() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_msg text; +BEGIN + IF TG_OP = 'UPDATE' + AND NEW.update_package IS NOT DISTINCT FROM OLD.update_package + AND NEW.version IS NOT DISTINCT FROM OLD.version + AND NEW.rollout_version IS NOT DISTINCT FROM OLD.rollout_version + THEN + RETURN NEW; + END IF; + + v_msg := public.channel_update_package_mismatch(NEW.update_package, NEW.version, NEW.name); + IF v_msg IS NOT NULL THEN + RAISE EXCEPTION '%', v_msg + USING ERRCODE = '22023'; + END IF; + + v_msg := public.channel_update_package_mismatch(NEW.update_package, NEW.rollout_version, NEW.name); + IF v_msg IS NOT NULL THEN + RAISE EXCEPTION '%', v_msg + USING ERRCODE = '22023'; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.enforce_channel_update_package_bundle() OWNER TO postgres; +REVOKE ALL ON FUNCTION public.enforce_channel_update_package_bundle() FROM PUBLIC; +GRANT ALL ON FUNCTION public.enforce_channel_update_package_bundle() TO service_role; + +COMMENT ON FUNCTION public.enforce_channel_update_package_bundle() IS + 'Blocks channel writes that pair zip-only or delta-only package modes with a bundle that cannot serve that package, including rollout targets.'; + +DROP TRIGGER IF EXISTS enforce_channel_update_package_bundle ON public.channels; +CREATE TRIGGER enforce_channel_update_package_bundle +BEFORE INSERT OR UPDATE OF version, rollout_version, update_package ON public.channels +FOR EACH ROW +EXECUTE FUNCTION public.enforce_channel_update_package_bundle(); diff --git a/tests/channel-post.unit.test.ts b/tests/channel-post.unit.test.ts index e0fb91a636..a19545c60c 100644 --- a/tests/channel-post.unit.test.ts +++ b/tests/channel-post.unit.test.ts @@ -329,6 +329,48 @@ describe('public channel post', () => { ) }) + it('maps updatePackage onto the channel row', async () => { + supabaseAdmin.mockImplementation(() => buildAdminChain({ + existingChannelId: 42, + existingChannelVersion: 123, + existingChannelPublic: true, + })) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + const c = context() + + await post(c, { + app_id: 'com.test.update-package', + channel: 'production', + updatePackage: 'zip_from_builtin', + }, apiKey()) + + expect(updateOrCreateChannel).toHaveBeenCalledWith( + c, + expect.objectContaining({ update_package: 'zip_from_builtin' }), + 42, + true, + ) + }) + + it('maps zip package mismatches from the database', async () => { + supabaseAdmin.mockImplementation(() => buildAdminChain({ + existingChannelId: 42, + existingChannelVersion: 123, + existingChannelPublic: true, + })) + updateOrCreateChannel.mockRejectedValue(new Error('CHANNEL_ZIP_REQUIRED: Channel "production" requires a zip package, but bundle "1.2.3" has no zip. Upload a full zip (omit --delta-only) or set the channel to delta only / zip and delta.')) + const { post } = await import('../supabase/functions/_backend/public/channel/post.ts') + const c = context() + + await expect(post(c, { + app_id: 'com.test.update-package', + channel: 'production', + updatePackage: 'zip', + }, apiKey())).rejects.toMatchObject({ + cause: expect.objectContaining({ error: 'channel_zip_required' }), + }) + }) + it('preserves the stable version for a settings-only update without channel.read or bundle lookup', async () => { const fromCalls: string[] = [] supabaseAdmin.mockImplementation(() => buildAdminChain({ diff --git a/tests/channel-update-package-error.unit.test.ts b/tests/channel-update-package-error.unit.test.ts new file mode 100644 index 0000000000..8c81e9e35d --- /dev/null +++ b/tests/channel-update-package-error.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { channelUpdatePackageErrorKey } from '../src/services/channelUpdatePackageError.ts' + +describe('channelUpdatePackageErrorKey', () => { + it('maps zip mismatch messages', () => { + expect(channelUpdatePackageErrorKey({ + message: 'CHANNEL_ZIP_REQUIRED: Channel "production" requires a zip package, but bundle "1.2.3" has no zip.', + })).toBe('update-package-zip-required') + }) + + it('maps delta mismatch messages', () => { + expect(channelUpdatePackageErrorKey({ + message: 'CHANNEL_DELTA_REQUIRED: Channel "production" requires delta files, but bundle "1.0.0" has none.', + })).toBe('update-package-delta-required') + }) + + it('maps API error codes', () => { + expect(channelUpdatePackageErrorKey({ + error: 'channel_zip_required', + message: 'This channel requires a zip package, but the bundle has no zip.', + })).toBe('update-package-zip-required') + expect(channelUpdatePackageErrorKey({ + error: 'channel_delta_required', + message: 'This channel requires a delta package, but the bundle has no delta files.', + })).toBe('update-package-delta-required') + }) + + it('ignores unrelated errors', () => { + expect(channelUpdatePackageErrorKey({ message: 'permission denied' })).toBeNull() + expect(channelUpdatePackageErrorKey(null)).toBeNull() + }) +}) diff --git a/tests/channel.test.ts b/tests/channel.test.ts index 6639d73e1b..ee85bde2a3 100644 --- a/tests/channel.test.ts +++ b/tests/channel.test.ts @@ -1,12 +1,30 @@ import { randomUUID } from 'node:crypto' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { BASE_URL, headers, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils.ts' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { BASE_URL, createAppVersions, getSupabaseClient, headers, resetAndSeedAppData, resetAppData, resetAppDataStats } from './test-utils.ts' const id = randomUUID() const APPNAME = `com.app.c.${id}` +let productionVersionId: number | null = null beforeAll(async () => { await resetAndSeedAppData(APPNAME) + const { data } = await getSupabaseClient() + .from('app_versions') + .select('id') + .eq('app_id', APPNAME) + .eq('name', '1.0.0') + .single() + .throwOnError() + productionVersionId = data.id +}) +afterEach(async () => { + const client = getSupabaseClient() + await client + .from('channels') + .update({ update_package: 'all', ...(productionVersionId ? { version: productionVersionId } : {}) }) + .eq('app_id', APPNAME) + .eq('name', 'production') + .throwOnError() }) afterAll(async () => { await resetAppData(APPNAME) @@ -81,6 +99,30 @@ describe('[POST] /channel operations', () => { expect(data.status).toBe('ok') }) + it('update channel package mode', async () => { + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + updatePackage: 'zip', + }), + }) + + const data = await response.json<{ status: string }>() + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + + const getResponse = await fetch(`${BASE_URL}/channel?${new URLSearchParams({ app_id: APPNAME, channel: 'production' }).toString()}`, { + method: 'GET', + headers, + }) + const channel = await getResponse.json<{ updatePackage: string }>() + expect(getResponse.status).toBe(200) + expect(channel.updatePackage).toBe('zip') + }) + it('invalid app_id', async () => { const response = await fetch(`${BASE_URL}/channel`, { method: 'POST', @@ -95,6 +137,179 @@ describe('[POST] /channel operations', () => { }) }) +describe('channel update package vs bundle compatibility', () => { + async function insertDelta(versionId: number) { + await getSupabaseClient() + .from('manifest') + .insert({ + app_version_id: versionId, + file_name: `pkg-${versionId}.js`, + s3_path: `/pkg-${versionId}.js`, + file_hash: `hash-${versionId}`, + file_size: 16, + }) + .throwOnError() + } + + async function productionChannel() { + const { data } = await getSupabaseClient() + .from('channels') + .select('id, version') + .eq('app_id', APPNAME) + .eq('name', 'production') + .single() + .throwOnError() + return data + } + + it('refuses delta-only when the current bundle has no delta files', async () => { + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + updatePackage: 'delta', + }), + }) + const data = await response.json<{ error?: string, message?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('channel_delta_required') + expect(data.message).toContain('no delta files') + expect(data.message).toContain('1.0.0') + }) + + it('refuses zip-only when assigning a delta-only bundle', async () => { + const deltaOnly = await createAppVersions(`1.0.delta-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'delta-only', + storage_provider: 'r2-direct', + r2_path: null, + }) + await insertDelta(deltaOnly.id) + + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + updatePackage: 'zip', + }), + }) + + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + version: deltaOnly.name, + }), + }) + const data = await response.json<{ error?: string, message?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('channel_zip_required') + expect(data.message).toContain('no zip') + expect(data.message).toContain(deltaOnly.name) + }) + + it('refuses assigning a zip-only bundle to a delta-only channel', async () => { + const withDelta = await createAppVersions(`1.0.both-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'zip-and-delta', + storage_provider: 'r2', + r2_path: `orgs/test/apps/${APPNAME}/both.zip`, + }) + await insertDelta(withDelta.id) + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + version: withDelta.name, + updatePackage: 'delta', + }), + }) + + const zipOnly = await createAppVersions(`1.0.zip-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'zip-only', + storage_provider: 'r2', + r2_path: `orgs/test/apps/${APPNAME}/zip-only.zip`, + }) + + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + version: zipOnly.name, + }), + }) + const data = await response.json<{ error?: string, message?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('channel_delta_required') + expect(data.message).toContain('no delta files') + expect(data.message).toContain(zipOnly.name) + }) + + it('refuses zip-only assignment through PUT /bundle', async () => { + const channel = await productionChannel() + const deltaOnly = await createAppVersions(`1.0.put-delta-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'put-delta-only', + storage_provider: 'r2-direct', + r2_path: null, + }) + await insertDelta(deltaOnly.id) + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'production', + updatePackage: 'zip', + }), + }) + + const response = await fetch(`${BASE_URL}/bundle`, { + method: 'PUT', + headers, + body: JSON.stringify({ + app_id: APPNAME, + version_id: deltaOnly.id, + channel_id: channel.id, + }), + }) + const data = await response.json<{ error?: string, message?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('channel_zip_required') + expect(data.message).toContain('no zip') + }) + + it('refuses an incompatible setting change through a console-style channel update', async () => { + const zipOnly = await createAppVersions(`1.0.ui-zip-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'ui-zip-only', + storage_provider: 'r2', + r2_path: `orgs/test/apps/${APPNAME}/ui-zip-only.zip`, + }) + await getSupabaseClient() + .from('channels') + .update({ version: zipOnly.id, update_package: 'all' }) + .eq('app_id', APPNAME) + .eq('name', 'production') + .throwOnError() + + const { error } = await getSupabaseClient() + .from('channels') + .update({ update_package: 'delta' }) + .eq('app_id', APPNAME) + .eq('name', 'production') + expect(error).toBeTruthy() + expect(error?.message).toContain('CHANNEL_DELTA_REQUIRED') + expect(error?.message).toContain('no delta files') + }) +}) + describe('[DELETE] /channel operations', () => { it('invalid channel', async () => { const response = await fetch(`${BASE_URL}/channel`, { diff --git a/tests/cli-channel.test.ts b/tests/cli-channel.test.ts index be9eb030cd..f79bc09423 100644 --- a/tests/cli-channel.test.ts +++ b/tests/cli-channel.test.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { createClient } from '@supabase/supabase-js' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createTestSDK } from './cli-sdk-utils' -import { BASE_URL, createDirectApiKeyWithBindings, createIsolatedSeedAppOptions, getSupabaseClient, resetAndSeedAppData, resetAppData, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_ID } from './test-utils' +import { BASE_URL, createAppVersions, createDirectApiKeyWithBindings, createIsolatedSeedAppOptions, getSupabaseClient, resetAndSeedAppData, resetAppData, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, USER_ID } from './test-utils' const seedOptions = createIsolatedSeedAppOptions() @@ -593,6 +593,107 @@ describe('tests CLI channel commands', () => { expect(data?.disable_auto_update).toBe('major') }) + it.concurrent('should set channel update package', async () => { + const testChannelName = generateChannelName() + await createChannel(testChannelName, APPNAME) + const withDelta = await createAppVersions(`1.0.cli-delta-set-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'cli-delta-set', + storage_provider: 'r2', + r2_path: `orgs/test/apps/${APPNAME}/cli-delta-set.zip`, + }) + await getSupabaseClient() + .from('manifest') + .insert({ + app_version_id: withDelta.id, + file_name: `cli-delta-${testChannelName}.js`, + s3_path: `/cli-delta-${testChannelName}.js`, + file_hash: 'cli-delta', + file_size: 8, + }) + .throwOnError() + await getSupabaseClient() + .from('channels') + .update({ version: withDelta.id }) + .eq('app_id', APPNAME) + .eq('name', testChannelName) + .throwOnError() + + const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, updatePackage: 'delta' }) + expect(result.success).toBe(true) + + const { data, error } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('name', testChannelName) + .eq('app_id', APPNAME) + .single() + .throwOnError() + expect(error).toBeNull() + expect(data?.update_package).toBe('delta') + }) + + it.concurrent('should refuse delta-only when the linked bundle has no delta files', async () => { + const testChannelName = generateChannelName() + await createChannel(testChannelName, APPNAME) + const zipOnly = await createAppVersions(`1.0.cli-zip-${randomUUID().slice(0, 8)}`, APPNAME, { + checksum: 'cli-zip-only', + storage_provider: 'r2', + r2_path: `orgs/test/apps/${APPNAME}/cli-zip-only.zip`, + }) + await getSupabaseClient() + .from('channels') + .update({ version: zipOnly.id, update_package: 'all' }) + .eq('app_id', APPNAME) + .eq('name', testChannelName) + .throwOnError() + + const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: undefined, updatePackage: 'delta' }) + expect(result.success).toBe(false) + expect(result.error).toContain('CHANNEL_DELTA_REQUIRED') + expect(result.error).toContain('no delta files') + }) + + it.concurrent('should refuse assigning a delta-only bundle to a zip-only channel', async () => { + const testChannelName = generateChannelName() + await createChannel(testChannelName, APPNAME) + + const { data: zipVersion } = await getSupabaseClient() + .from('app_versions') + .select('id, name') + .eq('app_id', APPNAME) + .eq('name', '1.0.0') + .single() + .throwOnError() + await getSupabaseClient() + .from('channels') + .update({ version: zipVersion.id, update_package: 'zip' }) + .eq('app_id', APPNAME) + .eq('name', testChannelName) + .throwOnError() + + const deltaOnly = `1.0.cli-delta-${randomUUID().slice(0, 8)}` + const version = await createAppVersions(deltaOnly, APPNAME, { + checksum: 'cli-delta-only', + storage_provider: 'r2-direct', + r2_path: null, + }) + await getSupabaseClient() + .from('manifest') + .insert({ + app_version_id: version.id, + file_name: `${deltaOnly}.js`, + s3_path: `/${deltaOnly}.js`, + file_hash: 'cli-delta-only', + file_size: 8, + }) + .throwOnError() + + const result = await createTestSDK().updateChannel({ channelId: testChannelName, appId: APPNAME, bundle: deltaOnly }) + expect(result.success).toBe(false) + expect(result.error).toContain('CHANNEL_ZIP_REQUIRED') + expect(result.error).toContain('no zip') + }) + it.concurrent('should set channel for dev environment', async () => { const testChannelName = generateChannelName() await createChannel(testChannelName, APPNAME) diff --git a/tests/cli-sdk-utils.ts b/tests/cli-sdk-utils.ts index 8e9d4bee2d..a39083d782 100644 --- a/tests/cli-sdk-utils.ts +++ b/tests/cli-sdk-utils.ts @@ -685,6 +685,7 @@ export function createTestSDK(apikey: string = APIKEY_TEST_ORG_SUPER_ADMIN) { android, selfAssign, disableAutoUpdate, + updatePackage, dev, emulator, device, @@ -699,6 +700,7 @@ export function createTestSDK(apikey: string = APIKEY_TEST_ORG_SUPER_ADMIN) { android?: boolean selfAssign?: boolean disableAutoUpdate?: Database['public']['Enums']['disable_update'] + updatePackage?: Database['public']['Enums']['channel_update_package'] dev?: boolean emulator?: boolean device?: boolean @@ -723,6 +725,7 @@ export function createTestSDK(apikey: string = APIKEY_TEST_ORG_SUPER_ADMIN) { || android != null || selfAssign != null || disableAutoUpdate != null + || updatePackage != null || dev != null || emulator != null || device != null @@ -754,6 +757,7 @@ export function createTestSDK(apikey: string = APIKEY_TEST_ORG_SUPER_ADMIN) { ...(typeof android === 'boolean' ? { android } : {}), ...(typeof selfAssign === 'boolean' ? { allow_device_self_set: selfAssign } : {}), ...(disableAutoUpdate == null ? {} : { disable_auto_update: disableAutoUpdate }), + ...(updatePackage == null ? {} : { update_package: updatePackage }), ...(typeof dev === 'boolean' ? { allow_dev: dev } : {}), ...(typeof emulator === 'boolean' ? { allow_emulator: emulator } : {}), ...(typeof device === 'boolean' ? { allow_device: device } : {}), diff --git a/tests/update-response-shaping.unit.test.ts b/tests/update-response-shaping.unit.test.ts index 85652264eb..b3b8aa1b13 100644 --- a/tests/update-response-shaping.unit.test.ts +++ b/tests/update-response-shaping.unit.test.ts @@ -1,6 +1,6 @@ import type { Database } from '../supabase/functions/_backend/utils/supabase.types.ts' import { describe, expect, it, vi } from 'vitest' -import { resToVersion } from '../supabase/functions/_backend/plugin_runtime/utils/update.ts' +import { isOnBuiltinVersion, resolveChannelUpdatePackage, resToVersion } from '../supabase/functions/_backend/plugin_runtime/utils/update.ts' vi.mock('../supabase/functions/_backend/plugin_runtime/utils/org_email_notifications.ts', () => ({ sendNotifToOrgMembersCached: vi.fn(() => Promise.resolve()), @@ -43,3 +43,28 @@ describe('update response shaping', () => { expect(response).not.toHaveProperty('comment') }) }) + +describe('channel update package resolution', () => { + it.concurrent('keeps both zip and delta by default', () => { + expect(resolveChannelUpdatePackage('all', false)).toBe('all') + expect(resolveChannelUpdatePackage('all', true)).toBe('all') + expect(resolveChannelUpdatePackage(undefined, true)).toBe('all') + }) + + it.concurrent('forces zip or delta for every device', () => { + expect(resolveChannelUpdatePackage('zip', false)).toBe('zip') + expect(resolveChannelUpdatePackage('zip', true)).toBe('zip') + expect(resolveChannelUpdatePackage('delta', false)).toBe('delta') + expect(resolveChannelUpdatePackage('delta', true)).toBe('delta') + }) + + it.concurrent('applies builtin-only modes only on the store binary', () => { + expect(resolveChannelUpdatePackage('zip_from_builtin', true)).toBe('zip') + expect(resolveChannelUpdatePackage('zip_from_builtin', false)).toBe('all') + expect(resolveChannelUpdatePackage('delta_from_builtin', true)).toBe('delta') + expect(resolveChannelUpdatePackage('delta_from_builtin', false)).toBe('all') + expect(isOnBuiltinVersion('1.0.0', '1.0.0')).toBe(true) + expect(isOnBuiltinVersion('builtin', '1.0.0')).toBe(true) + expect(isOnBuiltinVersion('1.0.1', '1.0.0')).toBe(false) + }) +}) diff --git a/tests/updates-manifest.test.ts b/tests/updates-manifest.test.ts index 92ef137b8c..1302842cad 100644 --- a/tests/updates-manifest.test.ts +++ b/tests/updates-manifest.test.ts @@ -120,6 +120,7 @@ afterEach(async () => { } if (versionId) { + await supabase.from('channels').update({ update_package: 'all' }).eq('app_id', APPNAME).eq('name', 'production').throwOnError() await setProductionVersion(versionId) } @@ -222,3 +223,126 @@ describe('update manifest scenarios', () => { expect(json.manifest).toBeDefined() }) }) + +describe('channel update package', () => { + async function setUpdatePackage(mode: 'all' | 'zip' | 'delta' | 'zip_from_builtin' | 'delta_from_builtin') { + await getSupabaseClient() + .from('channels') + .update({ update_package: mode }) + .eq('app_id', APPNAME) + .eq('name', 'production') + .throwOnError() + } + + async function requestUpdate(versionName: string, versionBuild = '1.0.0') { + const { data: versionData } = await getSupabaseClient() + .from('app_versions') + .select('id') + .eq('name', '1.0.0') + .eq('app_id', APPNAME) + .single() + if (!versionData) + throw new Error('Version data not found') + await insertManifestEntries(versionData.id) + + const baseData = getBaseData(APPNAME) + baseData.version_name = versionName + baseData.version_build = versionBuild + baseData.plugin_version = '6.25.0' + const response = await postUpdate(baseData) + expect(response.status).toBe(200) + return response.json() + } + + it('zip only omits the manifest', async () => { + await setUpdatePackage('zip') + const json = await requestUpdate('1.1.0') + expect(json.version).toBe('1.0.0') + expect(json.url).toBeDefined() + expect(json.url).not.toBe('https://404.capgo.app/no.zip') + expect(json.manifest).toBeUndefined() + }) + + it('delta only omits the zip url', async () => { + const { data: versionData } = await getSupabaseClient() + .from('app_versions') + .select('id') + .eq('name', '1.0.0') + .eq('app_id', APPNAME) + .single() + if (!versionData) + throw new Error('Version data not found') + await insertManifestEntries(versionData.id) + await setUpdatePackage('delta') + const json = await requestUpdate('1.1.0') + expect(json.version).toBe('1.0.0') + expect(json.manifest).toBeDefined() + expect(json.url).toBe('https://404.capgo.app/no.zip') + }) + + it('zip only from builtin applies only on the store binary', async () => { + const version = await createAppVersions(`1.0.${Math.floor(Math.random() * 100000) + 2000}`, APPNAME, { + checksum: 'pkg-zip-builtin', + r2_path: `orgs/test/apps/${APPNAME}/zip-builtin.zip`, + }) + createdVersionIds.push(version.id) + await insertManifestEntries(version.id) + await setProductionVersion(version.id) + await setUpdatePackage('zip_from_builtin') + + const builtinData = getBaseData(APPNAME) + builtinData.version_name = '1.0.0' + builtinData.version_build = '1.0.0' + builtinData.plugin_version = '6.25.0' + const builtinResponse = await postUpdate(builtinData) + expect(builtinResponse.status).toBe(200) + const builtinJson = await builtinResponse.json() + expect(builtinJson.version).toBe(version.name) + expect(builtinJson.manifest).toBeUndefined() + expect(builtinJson.url).not.toBe('https://404.capgo.app/no.zip') + + const otaData = getBaseData(APPNAME) + otaData.version_name = '1.1.0' + otaData.version_build = '1.0.0' + otaData.plugin_version = '6.25.0' + const otaResponse = await postUpdate(otaData) + expect(otaResponse.status).toBe(200) + const otaJson = await otaResponse.json() + expect(otaJson.version).toBe(version.name) + expect(otaJson.manifest).toBeDefined() + expect(otaJson.url).not.toBe('https://404.capgo.app/no.zip') + }) + + it('delta only from builtin applies only on the store binary', async () => { + const version = await createAppVersions(`1.0.${Math.floor(Math.random() * 100000) + 2000}`, APPNAME, { + checksum: 'pkg-delta-builtin', + r2_path: `orgs/test/apps/${APPNAME}/delta-builtin.zip`, + }) + createdVersionIds.push(version.id) + await insertManifestEntries(version.id) + await setProductionVersion(version.id) + await setUpdatePackage('delta_from_builtin') + + const builtinData = getBaseData(APPNAME) + builtinData.version_name = '1.0.0' + builtinData.version_build = '1.0.0' + builtinData.plugin_version = '6.25.0' + const builtinResponse = await postUpdate(builtinData) + expect(builtinResponse.status).toBe(200) + const builtinJson = await builtinResponse.json() + expect(builtinJson.version).toBe(version.name) + expect(builtinJson.manifest).toBeDefined() + expect(builtinJson.url).toBe('https://404.capgo.app/no.zip') + + const otaData = getBaseData(APPNAME) + otaData.version_name = '1.1.0' + otaData.version_build = '1.0.0' + otaData.plugin_version = '6.25.0' + const otaResponse = await postUpdate(otaData) + expect(otaResponse.status).toBe(200) + const otaJson = await otaResponse.json() + expect(otaJson.version).toBe(version.name) + expect(otaJson.manifest).toBeDefined() + expect(otaJson.url).not.toBe('https://404.capgo.app/no.zip') + }) +})