diff --git a/messages/en.json b/messages/en.json index 45fef41494..061bf41a2c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1977,6 +1977,19 @@ "storage-total": "Total", "storage-usage": "Storage usage: ", "stored-externally": "stored externally", + "store-release-onboarding-badge": "One onboarding step left", + "store-release-onboarding-body": "Capgo has seen a Live Update bundle for this app, but not a store-installed app with the updater plugin yet. This step disappears after a store build opens and checks in.", + "store-release-onboarding-body-testflight": "A TestFlight build has checked in. Publish the same native build to the App Store so production users get the updater plugin too.", + "store-release-onboarding-body-track-unknown": "An Android store build has checked in, but Android does not expose whether it came from production, alpha, beta, or internal testing. This does not complete the store-release step.", + "store-release-onboarding-builds": "Open Builds", + "store-release-onboarding-devices": "View Devices", + "store-release-onboarding-step-live-update": "Live Update tested", + "store-release-onboarding-step-store-release": "App Store build opened", + "store-release-onboarding-step-testflight": "TestFlight detected", + "store-release-onboarding-step-track-unknown": "Android store detected, release track unknown", + "store-release-onboarding-title": "Release an App Store build with Capgo installed.", + "store-release-onboarding-title-testflight": "TestFlight is detected. Publish the store build to finish onboarding.", + "store-release-onboarding-title-track-unknown": "Android store install detected, but the release track is unknown.", "test-preview": "Test preview", "stripe-billing-portal-will-be-opened-in-a-new-tab": "Stripe billing portal will be opened in a new tab", "subscribed-events": "Subscribed Events", diff --git a/src/components.d.ts b/src/components.d.ts index 9aca8ecc30..1961c6fb3b 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -81,6 +81,7 @@ declare module 'vue' { StepsApp: typeof import('./components/dashboard/StepsApp.vue')['default'] StepsBuild: typeof import('./components/dashboard/StepsBuild.vue')['default'] StepsBundle: typeof import('./components/dashboard/StepsBundle.vue')['default'] + StoreReleaseOnboardingBanner: typeof import('./components/dashboard/StoreReleaseOnboardingBanner.vue')['default'] TableLog: typeof import('./components/TableLog.vue')['default'] Tabs: typeof import('./components/Tabs.vue')['default'] TabSidebar: typeof import('./components/TabSidebar.vue')['default'] diff --git a/src/components/dashboard/StoreReleaseOnboardingBanner.vue b/src/components/dashboard/StoreReleaseOnboardingBanner.vue new file mode 100644 index 0000000000..be1c380b28 --- /dev/null +++ b/src/components/dashboard/StoreReleaseOnboardingBanner.vue @@ -0,0 +1,218 @@ + + + diff --git a/src/pages/app/[app].vue b/src/pages/app/[app].vue index 3a2a4b0b42..700b96dbbf 100644 --- a/src/pages/app/[app].vue +++ b/src/pages/app/[app].vue @@ -10,6 +10,7 @@ import DeploymentBanner from '~/components/dashboard/DeploymentBanner.vue' import DeploymentStatsCard from '~/components/dashboard/DeploymentStatsCard.vue' import DevicesStats from '~/components/dashboard/DevicesStats.vue' import ReleaseBanner from '~/components/dashboard/ReleaseBanner.vue' +import StoreReleaseOnboardingBanner from '~/components/dashboard/StoreReleaseOnboardingBanner.vue' import UpdateStatsCard from '~/components/dashboard/UpdateStatsCard.vue' import { getCapgoVersion, useSupabase } from '~/services/supabase' import { useDisplayStore } from '~/stores/display' @@ -203,6 +204,7 @@ watchEffect(async () => { + diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts index ca650f9e51..368c4e533d 100644 --- a/src/types/supabase.types.ts +++ b/src/types/supabase.types.ts @@ -1364,6 +1364,7 @@ export type Database = { default_channel: string | null device_id: string id: number + install_source: string | null is_emulator: boolean | null is_prod: boolean | null key_id: string | null @@ -1381,6 +1382,7 @@ export type Database = { default_channel?: string | null device_id: string id?: never + install_source?: string | null is_emulator?: boolean | null is_prod?: boolean | null key_id?: string | null @@ -1398,6 +1400,7 @@ export type Database = { default_channel?: string | null device_id?: string id?: never + install_source?: string | null is_emulator?: boolean | null is_prod?: boolean | null key_id?: string | null diff --git a/supabase/functions/_backend/private/devices.ts b/supabase/functions/_backend/private/devices.ts index df8aa71b76..3272d016fc 100644 --- a/supabase/functions/_backend/private/devices.ts +++ b/supabase/functions/_backend/private/devices.ts @@ -16,6 +16,7 @@ interface DataDevice { versionName?: string devicesId?: string[] deviceIds?: string[] // TODO: remove when migration is done + installSources?: string[] search?: string customIdMode?: boolean order?: Order[] @@ -36,6 +37,7 @@ const devicesBodySchema = type({ 'versionName?': safeQueryTextSchema, 'devicesId?': deviceIdSchema.array(), 'deviceIds?': deviceIdSchema.array(), + 'installSources?': safeQueryTextSchema.array(), 'search?': safeQueryTextSchema, 'customIdMode?': 'boolean', 'order?': orderItemSchema.array(), @@ -65,11 +67,12 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { } const devicesIds = body.devicesId ?? body.deviceIds ?? [] if (body.count) - return c.json({ count: await countDevices(c, body.appId, body.customIdMode ?? false, devicesIds, body.versionName, body.search?.trim()) }) + return c.json({ count: await countDevices(c, body.appId, body.customIdMode ?? false, devicesIds, body.versionName, body.search?.trim(), body.installSources) }) return c.json(await readDevices(c, { app_id: body.appId, version_name: body.versionName, deviceIds: devicesIds, + installSources: body.installSources, search: body.search, order: body.order, cursor: body.cursor, diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index c518657732..459a03c22b 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -24,6 +24,7 @@ interface publicDevice { custom_id: string is_prod: boolean is_emulator: boolean + install_source: string | null version_name: string | null app_id: string platform: Database['public']['Enums']['platform_os'] @@ -37,8 +38,8 @@ interface publicDevice { export function filterDeviceKeys(devices: DeviceRes[]) { return devices.map((device) => { - const { updated_at, device_id, custom_id, is_prod, is_emulator, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } = device - return { updated_at, device_id, custom_id, is_prod, is_emulator, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } + const { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } = device + return { updated_at, device_id, custom_id, is_prod, is_emulator, install_source, version_name, version, app_id, platform, plugin_version, os_version, version_build, key_id } }) } diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index 1f70e9b8c1..1aa903f0f3 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -279,6 +279,7 @@ export async function trackDevicesCF(c: Context, device: DeviceWithoutCreatedAt) comparableDevice.version_build ?? '', comparableDevice.default_channel ?? '', comparableDevice.key_id ?? '', + comparableDevice.install_source ?? '', ], doubles: [ platformValue, @@ -619,6 +620,7 @@ export async function countDevicesCF( deviceIds: string[] = [], versionName?: string, search?: string, + installSources?: string[], ) { // Use Analytics Engine DEVICE_INFO for counting devices const conditions = [`index1 = '${escapeSqlString(app_id)}'`] @@ -647,9 +649,19 @@ export async function countDevicesCF( if (versionName) conditions.push(`blob2 = '${escapeSqlString(versionName)}'`) - const query = `SELECT COUNT(DISTINCT blob1) AS total + const sourceFilter = installSources?.length + ? `WHERE install_source IN (${installSources.map(source => `'${escapeSqlString(source)}'`).join(', ')})` + : '' + const query = `SELECT COUNT(*) AS total +FROM ( + SELECT + blob1 AS device_id, + argMaxIf(blob9, timestamp, blob9 != '') AS install_source FROM device_info -WHERE ${conditions.join(' AND ')}` +WHERE ${conditions.join(' AND ')} + GROUP BY blob1 +) +${sourceFilter}` cloudlog({ requestId: c.get('requestId'), message: 'countDevicesCF query', query }) try { @@ -671,6 +683,7 @@ interface DeviceInfoCF { version_build: string default_channel: string key_id: string + install_source: string platform: number // 0 = android, 1 = ios is_prod: number // 0 or 1 is_emulator: number // 0 or 1 @@ -689,7 +702,7 @@ function getReadDevicesCFOrder(params: ReadDevicesParams): DevicesOrderCF | null return activeOrder ? { ascending: activeOrder.sortable === 'asc' } : null } -function buildReadDevicesCFCursorFilter(cursor: string | undefined, devicesOrder: DevicesOrderCF | null) { +function buildReadDevicesCFCursorCondition(cursor: string | undefined, devicesOrder: DevicesOrderCF | null) { if (!cursor) return '' @@ -699,12 +712,12 @@ function buildReadDevicesCFCursorFilter(cursor: string | undefined, devicesOrder const safeCursorDeviceId = escapeSqlString(cursorDeviceId) if (!devicesOrder) - return `WHERE device_id > '${safeCursorDeviceId}'` + return `device_id > '${safeCursorDeviceId}'` const safeCursorTime = escapeSqlString(cursorTime) const comparison = devicesOrder.ascending ? '>' : '<' - return `WHERE (updated_at ${comparison} toDateTime('${safeCursorTime}') OR (updated_at = toDateTime('${safeCursorTime}') AND device_id > '${safeCursorDeviceId}'))` + return `(updated_at ${comparison} toDateTime('${safeCursorTime}') OR (updated_at = toDateTime('${safeCursorTime}') AND device_id > '${safeCursorDeviceId}'))` } export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: boolean) { @@ -740,7 +753,11 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: } const devicesOrder = getReadDevicesCFOrder(params) - const cursorFilter = buildReadDevicesCFCursorFilter(params.cursor, devicesOrder) + const outerConditions = [ + buildReadDevicesCFCursorCondition(params.cursor, devicesOrder), + params.installSources?.length ? `install_source IN (${params.installSources.map(source => `'${escapeSqlString(source)}'`).join(', ')})` : '', + ].filter(Boolean) + const outerFilter = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '' let orderBy = 'device_id ASC' if (devicesOrder) { const updatedAtDirection = devicesOrder.ascending ? 'ASC' : 'DESC' @@ -759,6 +776,7 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: ' argMax(blob6, timestamp) AS version_build,', ' argMax(blob7, timestamp) AS default_channel,', ' argMax(blob8, timestamp) AS key_id,', + ' argMaxIf(blob9, timestamp, blob9 != \'\') AS install_source,', ' argMax(double1, timestamp) AS platform,', ' argMax(double2, timestamp) AS is_prod,', ' argMax(double3, timestamp) AS is_emulator,', @@ -767,7 +785,7 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: ` WHERE ${conditions.join(' AND ')}`, ' GROUP BY blob1', ')', - cursorFilter, + outerFilter, `ORDER BY ${orderBy}`, `LIMIT ${limit + 1}`, ].filter(Boolean).join('\n') @@ -779,7 +797,7 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: export async function readDevicesCF(c: Context, params: ReadDevicesParams, customIdMode: boolean): Promise { // Use Analytics Engine DEVICE_INFO for reading devices // Schema: blob1=device_id, blob2=version_name, blob3=plugin_version, blob4=os_version, - // blob5=custom_id, blob6=version_build, blob7=default_channel, blob8=key_id + // blob5=custom_id, blob6=version_build, blob7=default_channel, blob8=key_id, blob9=install_source // double1=platform (0=android, 1=ios), double2=is_prod, double3=is_emulator // index1=app_id, timestamp=updated_at @@ -810,6 +828,7 @@ export async function readDevicesCF(c: Context, params: ReadDevicesParams, custo version_build: row.version_build, is_prod: Boolean(row.is_prod), is_emulator: Boolean(row.is_emulator), + install_source: row.install_source || null, custom_id: row.custom_id, updated_at: formatDateCF(row.updated_at), default_channel: row.default_channel || null, diff --git a/supabase/functions/_backend/utils/deviceComparison.ts b/supabase/functions/_backend/utils/deviceComparison.ts index 97eaee2ea8..7aa4020279 100644 --- a/supabase/functions/_backend/utils/deviceComparison.ts +++ b/supabase/functions/_backend/utils/deviceComparison.ts @@ -12,6 +12,7 @@ export interface DeviceComparable { version_name: string | null // DB schema: text (NULLABLE) is_prod: boolean is_emulator: boolean + install_source?: string | null default_channel: string | null // DB schema: TEXT (NULLABLE) key_id: string | null } @@ -26,6 +27,7 @@ export type DeviceExistingRowLike = { version_name?: string | null is_prod?: boolean | number | null is_emulator?: boolean | number | null + install_source?: string | null default_channel?: string | null key_id?: string | null } | null | undefined @@ -40,8 +42,9 @@ export function toComparableDevice(device: DeviceWithoutCreatedAt): DeviceCompar const normalizedDefaultChannel = normalizeOptionalString(device.default_channel) const normalizedVersionBuild = normalizeOptionalString(device.version_build) const normalizedKeyId = normalizeOptionalString(device.key_id) + const normalizedInstallSource = normalizeOptionalString(device.install_source) - return { + const comparable: DeviceComparable = { // version: device.version ?? null, platform: device.platform ?? null, // DB schema: plugin_version NOT NULL (must provide empty string) @@ -60,6 +63,9 @@ export function toComparableDevice(device: DeviceWithoutCreatedAt): DeviceCompar default_channel: normalizedDefaultChannel, key_id: normalizedKeyId, } + if (normalizedInstallSource !== null) + comparable.install_source = normalizedInstallSource + return comparable } export function toComparableExisting(existing: DeviceExistingRowLike): DeviceComparable { @@ -71,8 +77,9 @@ export function toComparableExisting(existing: DeviceExistingRowLike): DeviceCom const normalizedDefaultChannel = normalizeOptionalString(existing?.default_channel as string | null | undefined) const normalizedVersionBuild = normalizeOptionalString(existing?.version_build as string | null | undefined) const normalizedKeyId = normalizeOptionalString(existing?.key_id as string | null | undefined) + const normalizedInstallSource = normalizeOptionalString(existing?.install_source) - return { + const comparable: DeviceComparable = { // version: existing?.version ?? null, platform: existing?.platform ?? null, // DB schema: plugin_version NOT NULL (no default, must provide empty string) @@ -91,6 +98,9 @@ export function toComparableExisting(existing: DeviceExistingRowLike): DeviceCom default_channel: normalizedDefaultChannel, key_id: normalizedKeyId, } + if (normalizedInstallSource !== null) + comparable.install_source = normalizedInstallSource + return comparable } export function hasComparableDeviceChanged(existing: DeviceExistingRowLike, device: DeviceWithoutCreatedAt) { @@ -115,6 +125,7 @@ export function buildNormalizedDeviceForWrite(device: DeviceWithoutCreatedAt) { custom_id: comparableDevice.custom_id, is_prod: comparableDevice.is_prod, is_emulator: comparableDevice.is_emulator, + install_source: comparableDevice.install_source, key_id: comparableDevice.key_id, } } diff --git a/supabase/functions/_backend/utils/plugin_parser.ts b/supabase/functions/_backend/utils/plugin_parser.ts index 8e142b104c..edacbbd727 100644 --- a/supabase/functions/_backend/utils/plugin_parser.ts +++ b/supabase/functions/_backend/utils/plugin_parser.ts @@ -18,6 +18,13 @@ function normalizeCustomId(customId: unknown): string | undefined { return trimmed === '' ? undefined : trimmed } +function normalizeInstallSource(installSource: unknown): string | undefined { + if (typeof installSource !== 'string') + return undefined + const trimmed = installSource.trim().toLowerCase() + return trimmed === '' ? undefined : trimmed +} + function getInvalidCode(c: Context) { return c.req.method === 'GET' || c.req.method === 'DELETE' ? 'invalid_query_parameters' : 'invalid_json_body' } @@ -37,6 +44,7 @@ export function makeDevice(devBody: AppInfos | DeviceLink | AppStats, allowCusto version_name: devBody.version_name, is_emulator: devBody.is_emulator ?? false, is_prod: devBody.is_prod ?? true, + install_source: normalizeInstallSource(devBody.install_source), custom_id: customId, updated_at: new Date().toISOString(), default_channel: devBody.defaultChannel ?? null, @@ -97,6 +105,7 @@ export function convertQueryToBody(query: Record): DeviceLink { custom_id: query.custom_id, is_emulator: query.is_emulator === 'true', is_prod: query.is_prod === 'true', + install_source: query.install_source, version_os: query.version_os, key_id: query.key_id, } diff --git a/supabase/functions/_backend/utils/plugin_validation.ts b/supabase/functions/_backend/utils/plugin_validation.ts index e697323a59..ac88c9a579 100644 --- a/supabase/functions/_backend/utils/plugin_validation.ts +++ b/supabase/functions/_backend/utils/plugin_validation.ts @@ -105,6 +105,15 @@ function validateOptionalStringMaxLength( return value } +function validateInstallSourceValue(value: unknown, issues: ValidationIssue[]) { + if (typeof value !== 'string') { + issues.push(fieldIssue('install_source', 'install_source must be a string')) + return + } + if (value.length > 64) + issues.push(fieldIssue('install_source', 'String must contain at most 64 character(s)')) +} + function validateRequiredAppId(input: UnknownRecord, issues: ValidationIssue[]): string | undefined { const value = validateRequiredString(input, 'app_id', issues, MISSING_STRING_APP_ID, NON_STRING_APP_ID) if (value === undefined) { @@ -249,6 +258,8 @@ function validateOptionalCommonStrings(input: UnknownRecord, issues: ValidationI validateOptionalString(input, 'old_version_name', issues) validateOptionalString(input, 'version_code', issues) validateOptionalString(input, 'plugin_version', issues) + if (input.install_source !== undefined) + validateInstallSourceValue(input.install_source, issues) validateOptionalStringMaxLength(input, 'custom_id', 36, issues) validateOptionalStringMaxLength(input, 'key_id', 20, issues) } @@ -283,6 +294,8 @@ export const updateRequestSchema = createPluginSchema((input, issues) validateRequiredDevicePlatform(input, issues) validateRequiredPluginVersion(input, issues) validateOptionalString(input, 'defaultChannel', issues) + if (input.install_source !== undefined) + validateInstallSourceValue(input.install_source, issues) validateOptionalStringMaxLength(input, 'key_id', 20, issues) }) @@ -309,6 +322,8 @@ export const channelSelfRequestSchema = createPluginSchema((input, iss validateOptionalString(input, 'defaultChannel', issues) validateOptionalString(input, 'channel', issues) validateOptionalString(input, 'plugin_version', issues) + if (input.install_source !== undefined) + validateInstallSourceValue(input.install_source, issues) validateOptionalStringMaxLength(input, 'key_id', 20, issues) }) diff --git a/supabase/functions/_backend/utils/stats.ts b/supabase/functions/_backend/utils/stats.ts index 8d31ce8ef7..3829aa2d61 100644 --- a/supabase/functions/_backend/utils/stats.ts +++ b/supabase/functions/_backend/utils/stats.ts @@ -329,14 +329,15 @@ export function countDevices( deviceIds: string[] = [], versionName?: string, search?: string, + installSources?: string[], ) { // Use Analytics Engine DEVICE_INFO when available in Cloudflare Workers. // In local Cloudflare testing these bindings are often absent, so fall back // to the Postgres/Supabase path. const trimmedSearch = search?.trim() if (shouldUseAnalyticsEngine(c)) - return countDevicesCF(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch) - return countDevicesSB(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch) + return countDevicesCF(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch, installSources) + return countDevicesSB(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch, installSources) } export async function readDevices(c: Context, params: ReadDevicesParams, customIdMode: boolean): Promise { diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index a69246bdfd..6b1866b82a 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1241,7 +1241,7 @@ export async function trackDevicesSB(c: Context, device: DeviceWithoutCreatedAt) const { data: existingRow, error } = await client .from('devices') - .select('version_name, platform, plugin_version, os_version, version_build, custom_id, is_prod, is_emulator, default_channel, key_id') + .select('version_name, platform, plugin_version, os_version, version_build, custom_id, is_prod, is_emulator, install_source, default_channel, key_id') .eq('app_id', device.app_id) .eq('device_id', device.device_id) .maybeSingle() @@ -1254,8 +1254,13 @@ export async function trackDevicesSB(c: Context, device: DeviceWithoutCreatedAt) // This avoids accidental clearing, and lets higher-level callers strip custom_id // (e.g., when an app disables device self-setting) without overwriting owner-set values. const requestedCustomId = nullableString(device.custom_id) - const deviceForWrite: DeviceWithoutCreatedAt = requestedCustomId === null && existingRow - ? { ...device, custom_id: existingRow.custom_id ?? '' } + const requestedInstallSource = nullableString(device.install_source) + const deviceForWrite: DeviceWithoutCreatedAt = existingRow + ? { + ...device, + ...(requestedCustomId === null ? { custom_id: existingRow.custom_id ?? '' } : {}), + ...(requestedInstallSource === null ? { install_source: existingRow.install_source ?? undefined } : {}), + } : device if (existingRow && !hasComparableDeviceChanged(existingRow, deviceForWrite)) { @@ -1279,6 +1284,7 @@ export async function trackDevicesSB(c: Context, device: DeviceWithoutCreatedAt) version_name: normalizedDevice.version_name ?? deviceForWrite.version_name, is_prod: normalizedDevice.is_prod, is_emulator: normalizedDevice.is_emulator, + ...(requestedInstallSource === null ? {} : { install_source: normalizedDevice.install_source ?? undefined }), default_channel: device.default_channel ?? null, key_id: normalizedDevice.key_id ?? undefined, } as Database['public']['Tables']['devices']['Insert'] @@ -1470,6 +1476,9 @@ export async function readDevicesSB(c: Context, params: ReadDevicesParams, custo if (params.version_name) query = query.eq('version_name', params.version_name) + if (params.installSources?.length) + query = query.in('install_source', params.installSources) + const devicesOrder = getDevicesOrder(params.order) if (params.cursor) { @@ -1512,6 +1521,7 @@ export async function countDevicesSB( deviceIds: string[] = [], versionName?: string, search?: string, + installSources?: string[], ) { let req = supabaseAdmin(c) .from('devices') @@ -1542,6 +1552,9 @@ export async function countDevicesSB( if (versionName) req = req.eq('version_name', versionName) + if (installSources?.length) + req = req.in('install_source', installSources) + const { count, error } = await req if (error) { diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index ca650f9e51..368c4e533d 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -1364,6 +1364,7 @@ export type Database = { default_channel: string | null device_id: string id: number + install_source: string | null is_emulator: boolean | null is_prod: boolean | null key_id: string | null @@ -1381,6 +1382,7 @@ export type Database = { default_channel?: string | null device_id: string id?: never + install_source?: string | null is_emulator?: boolean | null is_prod?: boolean | null key_id?: string | null @@ -1398,6 +1400,7 @@ export type Database = { default_channel?: string | null device_id?: string id?: never + install_source?: string | null is_emulator?: boolean | null is_prod?: boolean | null key_id?: string | null diff --git a/supabase/functions/_backend/utils/types.ts b/supabase/functions/_backend/utils/types.ts index 1b76ebccd2..c5b42f8ee1 100644 --- a/supabase/functions/_backend/utils/types.ts +++ b/supabase/functions/_backend/utils/types.ts @@ -14,6 +14,7 @@ export interface AppInfos { custom_id?: string is_prod?: boolean is_emulator?: boolean + install_source?: string plugin_version: string platform: string app_id: string @@ -71,6 +72,7 @@ export interface ReadDevicesParams { app_id: string version_name?: string | undefined deviceIds?: string[] + installSources?: string[] search?: string order?: Order[] limit?: number diff --git a/supabase/migrations/20260611171704_add_device_install_source.sql b/supabase/migrations/20260611171704_add_device_install_source.sql new file mode 100644 index 0000000000..0eceace793 --- /dev/null +++ b/supabase/migrations/20260611171704_add_device_install_source.sql @@ -0,0 +1,8 @@ +ALTER TABLE "public"."devices" +ADD COLUMN IF NOT EXISTS "install_source" "text"; + +COMMENT ON COLUMN "public"."devices"."install_source" IS 'Optional native install source reported by the updater plugin, for example app_store, testflight, or google_play. Android store sources only identify the installer, not the production/alpha/beta/internal track.'; + +CREATE INDEX IF NOT EXISTS "idx_devices_app_id_install_source" +ON "public"."devices" USING "btree" ("app_id", "install_source") +WHERE "install_source" IS NOT NULL; diff --git a/tests/cloudflare-device-pagination.unit.test.ts b/tests/cloudflare-device-pagination.unit.test.ts index d96da9e761..8cd163664b 100644 --- a/tests/cloudflare-device-pagination.unit.test.ts +++ b/tests/cloudflare-device-pagination.unit.test.ts @@ -13,6 +13,7 @@ function createReadDevicesQueryMock() { select: vi.fn(() => query), eq: vi.fn(() => query), gt: vi.fn(() => query), + in: vi.fn(() => query), order: vi.fn(() => query), limit: vi.fn(() => query), then: vi.fn((resolve, reject) => Promise.resolve({ data: [], error: null }).then(resolve, reject)), @@ -88,6 +89,23 @@ describe('buildReadDevicesCFQuery', () => { expect(tiebreakerIndex).toBeGreaterThan(cursorIndex) expect(query).toContain(`ORDER BY updated_at ASC, device_id ASC`) }) + + it.concurrent('filters install sources after device grouping', () => { + const query = buildReadDevicesCFQuery({ + app_id: 'com.example.app', + installSources: ['app_store', 'amazon_appstore'], + cursor: '2026-04-04 03:05:59|11111111-1111-4111-8111-111111111111', + limit: 1, + }, false) + + const groupByIndex = query.indexOf('GROUP BY blob1') + const installSourceFilterIndex = query.indexOf(`install_source IN ('app_store', 'amazon_appstore')`) + + expect(query).toContain('argMaxIf(blob9, timestamp, blob9 != \'\') AS install_source') + expect(installSourceFilterIndex).toBeGreaterThan(groupByIndex) + expect(query).not.toContain(`WHERE index1 = 'com.example.app' AND blob9 IN`) + expect(query).toContain(`WHERE device_id > '11111111-1111-4111-8111-111111111111' AND install_source IN ('app_store', 'amazon_appstore')`) + }) }) describe('readDevicesSB', () => { @@ -107,4 +125,17 @@ describe('readDevicesSB', () => { expect(query.order).toHaveBeenCalledWith('device_id', { ascending: true }) expect(query.limit).toHaveBeenCalledWith(2) }) + + it('applies install source filters', async () => { + const { client, query } = createReadDevicesQueryMock() + vi.mocked(createClient).mockReturnValue(client as unknown as ReturnType) + + await readDevicesSB(createContextMock() as unknown as Context, { + app_id: 'com.example.app', + installSources: ['app_store', 'testflight'], + limit: 1, + }, false) + + expect(query.in).toHaveBeenCalledWith('install_source', ['app_store', 'testflight']) + }) }) diff --git a/tests/device-update-format.unit.test.ts b/tests/device-update-format.unit.test.ts index 3d787eb0c2..6c157619f2 100644 --- a/tests/device-update-format.unit.test.ts +++ b/tests/device-update-format.unit.test.ts @@ -13,6 +13,7 @@ describe('device update format helpers', () => { default_channel: 'insiders', device_id: '31de6a5e-80a9-4348-9af1-31e1e9562583', id: 1, + install_source: null, is_emulator: false, is_prod: true, key_id: null, @@ -40,6 +41,7 @@ describe('device update format helpers', () => { custom_id: '', default_channel: 'insiders', device_id: '31de6a5e-80a9-4348-9af1-31e1e9562583', + install_source: null, id: 1, is_emulator: false, is_prod: true, diff --git a/tests/device_comparison.test.ts b/tests/device_comparison.test.ts index b03f90fe53..dcedaca94f 100644 --- a/tests/device_comparison.test.ts +++ b/tests/device_comparison.test.ts @@ -24,6 +24,7 @@ function simulateReplicaStorage(device: DeviceWithoutCreatedAt): DeviceExistingR version_name: comparable.version_name, // Already has 'unknown' default from toComparableDevice() is_prod: comparable.is_prod ? 1 : 0, is_emulator: comparable.is_emulator ? 1 : 0, + install_source: comparable.install_source, default_channel: comparable.default_channel, key_id: comparable.key_id, } @@ -65,6 +66,7 @@ describe('deviceComparison utilities', () => { version_name: 'v1.0.0', is_prod: true, is_emulator: false, + install_source: 'google_play', default_channel: 'production', } @@ -79,6 +81,7 @@ describe('deviceComparison utilities', () => { version_name: 'v1.0.0', is_prod: true, is_emulator: false, + install_source: 'google_play', default_channel: 'production', key_id: null, }) @@ -159,6 +162,7 @@ describe('deviceComparison utilities', () => { version_name: 'v1.0.0', is_prod: true, is_emulator: false, + install_source: 'app_store', default_channel: 'production', } @@ -173,6 +177,7 @@ describe('deviceComparison utilities', () => { version_name: 'v1.0.0', is_prod: true, is_emulator: false, + install_source: 'app_store', default_channel: 'production', key_id: null, }) @@ -240,6 +245,29 @@ describe('deviceComparison utilities', () => { }) describe('hasComparableDeviceChanged', () => { + it('should compare install_source only when the client reports it', () => { + const device: DeviceWithoutCreatedAt = { + device_id: 'test-device', + app_id: 'test-app', + platform: 'ios', + plugin_version: '8.0.0', + os_version: '18', + version_build: '100', + custom_id: '', + version_name: 'v1.0.0', + is_prod: true, + is_emulator: false, + default_channel: 'production', + } + const existing = simulateReplicaStorage({ + ...device, + install_source: 'app_store', + }) + + expect(hasComparableDeviceChanged(existing, device)).toBe(false) + expect(hasComparableDeviceChanged(existing, { ...device, install_source: 'testflight' })).toBe(true) + }) + it('should return false when devices are identical', () => { const existing: DeviceExistingRowLike = { platform: 'android', diff --git a/tests/organization-put-stripe-sync.unit.test.ts b/tests/organization-put-stripe-sync.unit.test.ts index 2cb3ffc7ef..d9a887dc68 100644 --- a/tests/organization-put-stripe-sync.unit.test.ts +++ b/tests/organization-put-stripe-sync.unit.test.ts @@ -77,6 +77,7 @@ function createOrgRow(overrides: Partial & Pick & Pick { }) }) +describe('test install_source', () => { + const schemasWithInstallSource = [updateRequestSchema, statsRequestSchema, channelSelfRequestSchema] + + schemasWithInstallSource.forEach((schema, index) => { + const suffix = index === 0 ? '- /updates' : index === 1 ? '- /stats' : '- /channel_self' + + it(`install_source valid ${suffix}`, () => { + const body = getJSON() + body.install_source = 'app_store' + const response = parseJSON(body, schema) + expect(response).toEqual(NO_ERROR) + }) + + it(`install_source invalid type ${suffix}`, () => { + const body = getJSON() + body.install_source = 123 + const response = parseJSON(body, schema) + expectError(response, 'install_source must be a string') + }) + + it(`install_source too long ${suffix}`, () => { + const body = getJSON() + body.install_source = 'a'.repeat(65) + const response = parseJSON(body, schema) + expectError(response, 'String must contain at most 64 character(s)') + }) + }) +}) + describe('test platform - /stats', () => { it('platform missing', () => { const body = getJSON() @@ -353,4 +384,5 @@ function expectError(response: any, expectedErrorMessage: string, errorIndex = 0 expect(response.nestedError).toBeDefined() expect(response.nestedError.issues[errorIndex]).toBeDefined() expect(response.nestedError.issues[errorIndex].message).toBeDefined() + expect(response.nestedError.issues[errorIndex].message).toContain(expectedErrorMessage) } diff --git a/tests/stats.test.ts b/tests/stats.test.ts index 3f45e47f10..eb55e26be8 100644 --- a/tests/stats.test.ts +++ b/tests/stats.test.ts @@ -22,6 +22,7 @@ type StatsAction = Database['public']['Enums']['stats_action'] interface StatsPayload extends ReturnType { action: StatsAction + install_source?: string metadata?: Record } @@ -125,6 +126,7 @@ describe('[POST] /stats', () => { const baseData = getBaseData(APP_NAME_STATS) as StatsPayload baseData.device_id = uuid baseData.action = 'set' + baseData.install_source = 'app_store' baseData.version_build = getVersionFromAction('set') const version = await createAppVersions(baseData.version_build, APP_NAME_STATS) @@ -139,6 +141,7 @@ describe('[POST] /stats', () => { expect(deviceData).toBeTruthy() expect(deviceData?.app_id).toBe(baseData.app_id) expect(deviceData?.version_name).toBe(version.name) + expect(deviceData?.install_source).toBe('app_store') // Check stats log const { error: statsError, data: statsData } = await getSupabaseClient().from('stats').select().eq('device_id', uuid).eq('app_id', APP_NAME_STATS).single()