diff --git a/src/services/versions.ts b/src/services/versions.ts index a04188e85f..3bed6f3b60 100644 --- a/src/services/versions.ts +++ b/src/services/versions.ts @@ -26,6 +26,7 @@ export function createBuiltinChannelVersion(channel: { cli_version: null, comment: null, created_at: channel.created_at, + created_by_apikey_rbac_id: null, deleted: false, deleted_at: null, external_url: null, diff --git a/supabase/functions/_backend/public/device/get.ts b/supabase/functions/_backend/public/device/get.ts index 5b2cd9d865..7ef867843a 100644 --- a/supabase/functions/_backend/public/device/get.ts +++ b/supabase/functions/_backend/public/device/get.ts @@ -16,6 +16,10 @@ interface GetDevice { cursor?: string /** Limit for results (default uses fetchLimit) */ limit?: number + /** ISO timestamp - only return devices with updated_at greater than this value */ + updated_at?: string + /** Sort devices by updated_at: asc or desc */ + order?: string } interface publicDevice { @@ -37,10 +41,75 @@ interface publicDevice { channel?: string } +function toPublicUpdatedAt(value: string): string { + // Cloudflare Analytics Engine returns UTC as "YYYY-MM-DD HH:mm:ss". + const cloudflareUtc = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(value) + const normalized = cloudflareUtc ? `${cloudflareUtc[1]}T${cloudflareUtc[2]}.000Z` : value + return new Date(normalized).toISOString() +} + +function parseUpdatedAtFilter(updatedAt: string | undefined): string | undefined { + if (!updatedAt) + return undefined + + // Accept ISO-8601 UTC (Z) and the Cloudflare device timestamp format for sync round-trips. + const cloudflareUtc = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(updatedAt) + const normalized = cloudflareUtc + ? `${cloudflareUtc[1]}-${cloudflareUtc[2]}-${cloudflareUtc[3]}T${cloudflareUtc[4]}:${cloudflareUtc[5]}:${cloudflareUtc[6]}.000Z` + : updatedAt + + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(normalized) + if (!match) { + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + } + + const parsed = new Date(normalized) + if (Number.isNaN(parsed.getTime())) + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + + const [, year, month, day, hour, minute, second] = match + if ( + parsed.getUTCFullYear() !== Number(year) + || parsed.getUTCMonth() + 1 !== Number(month) + || parsed.getUTCDate() !== Number(day) + || parsed.getUTCHours() !== Number(hour) + || parsed.getUTCMinutes() !== Number(minute) + || parsed.getUTCSeconds() !== Number(second) + ) { + throw simpleError('invalid_updated_at', 'updated_at must be a valid ISO date', { updated_at: updatedAt }) + } + + return parsed.toISOString() +} + +function parseDevicesOrder(order: string | undefined) { + if (!order) + return undefined + if (order !== 'asc' && order !== 'desc') + throw simpleError('invalid_order', 'order must be asc or desc', { order }) + return [{ key: 'updated_at', sortable: order as 'asc' | 'desc' }] +} + export function filterDeviceKeys(devices: DeviceRes[]) { return devices.map((device) => { 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, country_code } = 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, country_code } + return { + updated_at: updated_at ? toPublicUpdatedAt(updated_at) : 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, + country_code, + } }) } @@ -51,6 +120,7 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' if (!isValidAppId(body.app_id)) { throw simpleError('invalid_app_id', 'App ID must be a reverse domain string', { app_id: body.app_id }) } + // Auth context is already set by middlewareKey if (!(await checkPermission(c, 'app.read_devices', { appId: body.app_id }))) { throw simpleError('cannot_access_app', 'You can\'t access this app', { app_id: body.app_id }) @@ -100,10 +170,19 @@ export async function get(c: Context, body: GetDevice, apikey: Database['public' return c.json(dataDevice) } else { + const updatedAtGt = parseUpdatedAtFilter(body.updated_at) + const order = parseDevicesOrder(body.order) + const limit = body.limit == null ? fetchLimit : Number(body.limit) + if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 1) { + throw simpleError('invalid_limit', 'limit must be a positive integer', { limit: body.limit }) + } + const res = await readDevices(c, { app_id: body.app_id, cursor: body.cursor, - limit: body.limit ?? fetchLimit, + limit, + updated_at_gt: updatedAtGt, + order, }, body.customIdMode ?? false) if (!res?.data) { diff --git a/supabase/functions/_backend/utils/cloudflare.ts b/supabase/functions/_backend/utils/cloudflare.ts index e9db74d112..1234168341 100644 --- a/supabase/functions/_backend/utils/cloudflare.ts +++ b/supabase/functions/_backend/utils/cloudflare.ts @@ -959,8 +959,19 @@ function buildReadDevicesCFCursorCondition(cursor: string | undefined, devicesOr return `(updated_at ${comparison} toDateTime('${safeCursorTime}') OR (updated_at = toDateTime('${safeCursorTime}') AND device_id > '${safeCursorDeviceId}'))` } +function buildReadDevicesCFUpdatedAtGtCondition(updatedAtGt: string | undefined) { + if (!updatedAtGt) + return '' + + const safeUpdatedAtGt = escapeSqlString(formatDateCF(updatedAtGt)) + return `updated_at > toDateTime('${safeUpdatedAtGt}')` +} + function buildReadDevicesCFOuterConditions(params: ReadDevicesParams, devicesOrder: DevicesOrderCF | null) { - const conditions = [buildReadDevicesCFCursorCondition(params.cursor, devicesOrder)] + const conditions = [ + buildReadDevicesCFCursorCondition(params.cursor, devicesOrder), + buildReadDevicesCFUpdatedAtGtCondition(params.updated_at_gt), + ] return conditions.filter(Boolean) } @@ -996,6 +1007,11 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: conditions.push(`blob2 = '${escapeSqlString(params.version_name)}'`) } + if (params.updated_at_gt) { + const safeUpdatedAtGt = escapeSqlString(formatDateCF(params.updated_at_gt)) + conditions.push(`timestamp > toDateTime('${safeUpdatedAtGt}')`) + } + const devicesOrder = getReadDevicesCFOrder(params) const outerConditions = buildReadDevicesCFOuterConditions(params, devicesOrder) const includeCountryCode = !!params.deviceIds?.length diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 951337eb78..8960386828 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1605,6 +1605,9 @@ export async function readDevicesSB(c: Context, params: ReadDevicesParams, custo if (params.installSources?.length) query = query.in('install_source', params.installSources) + if (params.updated_at_gt) + query = query.gt('updated_at', params.updated_at_gt) + const devicesOrder = getDevicesOrder(params.order) if (params.cursor) { diff --git a/supabase/functions/_backend/utils/types.ts b/supabase/functions/_backend/utils/types.ts index ca969dbe27..6d080ab4a2 100644 --- a/supabase/functions/_backend/utils/types.ts +++ b/supabase/functions/_backend/utils/types.ts @@ -134,6 +134,8 @@ export interface ReadDevicesParams { installSources?: string[] search?: string order?: Order[] + /** Only return devices with updated_at greater than this ISO timestamp */ + updated_at_gt?: string limit?: number /** Cursor for pagination - use the last updated_at from previous page */ cursor?: string diff --git a/tests/cloudflare-device-pagination.unit.test.ts b/tests/cloudflare-device-pagination.unit.test.ts index c33af22326..2716d7e407 100644 --- a/tests/cloudflare-device-pagination.unit.test.ts +++ b/tests/cloudflare-device-pagination.unit.test.ts @@ -133,6 +133,23 @@ describe('buildReadDevicesCFQuery', () => { expect(query).toContain('GROUP BY blob1') expect(query).toContain(`install_source IN ('app_store', 'amazon_appstore')`) }) + it.concurrent('filters devices with updated_at greater than the provided timestamp', () => { + const query = buildReadDevicesCFQuery({ + app_id: 'com.example.app', + updated_at_gt: '2026-04-04T03:05:59.000Z', + limit: 1, + }, false) + + const groupByIndex = query.indexOf('GROUP BY blob1') + const innerFilterIndex = query.indexOf(`timestamp > toDateTime('2026-04-04 03:05:59')`) + const outerFilterIndex = query.indexOf(`WHERE updated_at > toDateTime('2026-04-04 03:05:59')`) + + expect(groupByIndex).toBeGreaterThan(-1) + expect(innerFilterIndex).toBeGreaterThan(-1) + expect(innerFilterIndex).toBeLessThan(groupByIndex) + expect(outerFilterIndex).toBeGreaterThan(groupByIndex) + }) + }) describe('countDevicesCF', () => { it('does not read install source blob on default device counts', async () => { @@ -291,4 +308,31 @@ describe('readDevicesSB', () => { expect(query.in).toHaveBeenCalledWith('install_source', ['app_store', 'testflight']) }) + + it('filters devices with updated_at greater than the provided timestamp', 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', + updated_at_gt: '2026-04-04T03:05:59.000Z', + limit: 1, + }, false) + + expect(query.gt).toHaveBeenCalledWith('updated_at', '2026-04-04T03:05:59.000Z') + }) + + it('orders devices by updated_at when requested', 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', + order: [{ key: 'updated_at', sortable: 'desc' }], + limit: 1, + }, false) + + expect(query.order).toHaveBeenCalledWith('updated_at', { ascending: false }) + expect(query.order).toHaveBeenCalledWith('device_id', { ascending: true }) + }) }) diff --git a/tests/device.test.ts b/tests/device.test.ts index e9116ad1a1..7136e1b721 100644 --- a/tests/device.test.ts +++ b/tests/device.test.ts @@ -67,6 +67,136 @@ describe.concurrent('[GET] /device operations', () => { }) }) + +describe('[GET] /device updated_at filter and order', () => { + it('filters devices by updated_at greater than ISO date', async () => { + const supabase = getSupabaseClient() + const pastDeviceId = randomUUID().toLowerCase() + const futureDeviceId = randomUUID().toLowerCase() + const cutoff = new Date('2026-01-01T00:00:00.000Z') + + const { error: insertError } = await supabase.from('devices').upsert([ + { + app_id: APPNAME_DEVICE, + device_id: pastDeviceId, + platform: 'ios', + plugin_version: '6.0.0', + os_version: '17.0', + version_build: '1.0.0', + version_name: '1.0.0', + is_prod: true, + is_emulator: false, + updated_at: '2025-06-01T00:00:00.000Z', + }, + { + app_id: APPNAME_DEVICE, + device_id: futureDeviceId, + platform: 'android', + plugin_version: '6.0.0', + os_version: '14', + version_build: '1.0.0', + version_name: '1.0.0', + is_prod: true, + is_emulator: false, + updated_at: '2026-06-01T00:00:00.000Z', + }, + ]) + expect(insertError).toBeNull() + + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: cutoff.toISOString(), + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ data: { device_id: string, updated_at: string }[], error?: string }>() + expect(response.status).toBe(200) + expect(data.error).toBeUndefined() + expect(data.data.some(device => device.device_id === futureDeviceId)).toBe(true) + expect(data.data.some(device => device.device_id === pastDeviceId)).toBe(false) + expect(data.data.every(device => new Date(device.updated_at).getTime() > cutoff.getTime())).toBe(true) + }) + + it('sorts devices by updated_at desc', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + order: 'desc', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ data: { updated_at: string }[], error?: string }>() + expect(response.status).toBe(200) + expect(data.error).toBeUndefined() + expect(data.data.length).toBeGreaterThan(1) + for (let i = 1; i < data.data.length; i++) { + expect(new Date(data.data[i - 1].updated_at).getTime()).toBeGreaterThanOrEqual(new Date(data.data[i].updated_at).getTime()) + } + }) + + it('rejects invalid updated_at filter', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: 'not-a-date', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_updated_at') + }) + + it('rejects non-calendar ISO updated_at values', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + updated_at: '2024-02-31T00:00:00.000Z', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_updated_at') + }) + + it('rejects invalid order', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + order: 'sideways', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ error?: string }>() + expect(response.status).toBe(400) + expect(data.error).toBe('invalid_order') + }) + + it('ignores list-only params when fetching a specific device', async () => { + const params = new URLSearchParams({ + app_id: APPNAME_DEVICE, + device_id: '00000000-0000-0000-0000-000000000000', + order: 'sideways', + updated_at: 'not-a-date', + limit: '1.5', + }) + const response = await fetch(`${BASE_URL}/device?${params.toString()}`, { + method: 'GET', + headers, + }) + const data = await response.json<{ device_id?: string, error?: string }>() + expect(response.status).toBe(200) + expect(data.device_id).toBe('00000000-0000-0000-0000-000000000000') + }) +}) + describe('[POST] /device operations', () => { it('link device', async () => { const deviceId = randomUUID().toLowerCase()