Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/services/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 81 additions & 2 deletions supabase/functions/_backend/public/device/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: High-precision cutoffs lose fractional digits beyond milliseconds, so updated_at_gt can return devices at or before the requested timestamp. Preserving the validated normalized timestamp for the backend comparison would retain the advertised strict greater-than semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_backend/public/device/get.ts, line 66:

<comment>High-precision cutoffs lose fractional digits beyond milliseconds, so `updated_at_gt` can return devices at or before the requested timestamp. Preserving the validated `normalized` timestamp for the backend comparison would retain the advertised strict greater-than semantics.</comment>

<file context>
@@ -41,23 +41,29 @@ interface publicDevice {
   }
 
-  const parsed = new Date(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 })
</file context>
Fix with cubic

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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
}
})
}

Expand All @@ -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 })
Expand Down Expand Up @@ -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) {
Expand Down
18 changes: 17 additions & 1 deletion supabase/functions/_backend/utils/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -959,12 +959,23 @@
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),
Comment thread
riderx marked this conversation as resolved.
]
return conditions.filter(Boolean)
}

export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode: boolean) {

Check failure on line 978 in supabase/functions/_backend/utils/cloudflare.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Cap-go_capgo&issues=AZ-JBfx4yMKZfQoLQqeH&open=AZ-JBfx4yMKZfQoLQqeH&pullRequest=2719
const limit = normalizeAnalyticsLimit(params.limit)
const conditions: string[] = [`index1 = '${escapeSqlString(params.app_id)}'`]

Expand Down Expand Up @@ -996,6 +1007,11 @@
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}')`)

@cubic-dev-ai cubic-dev-ai Bot Jul 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Filtered reads that include country data can now return country_code: null for a recently updated device whose latest country-bearing row predates the cutoff. The timestamp qualification should identify updated devices without removing older rows needed by the non-empty country_code aggregation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_backend/utils/cloudflare.ts, line 1012:

<comment>Filtered reads that include country data can now return `country_code: null` for a recently updated device whose latest country-bearing row predates the cutoff. The timestamp qualification should identify updated devices without removing older rows needed by the non-empty `country_code` aggregation.</comment>

<file context>
@@ -1007,6 +1007,11 @@ export function buildReadDevicesCFQuery(params: ReadDevicesParams, customIdMode:
 
+  if (params.updated_at_gt) {
+    const safeUpdatedAtGt = escapeSqlString(formatDateCF(params.updated_at_gt))
+    conditions.push(`timestamp > toDateTime('${safeUpdatedAtGt}')`)
+  }
+
</file context>
Fix with cubic

}

const devicesOrder = getReadDevicesCFOrder(params)
const outerConditions = buildReadDevicesCFOuterConditions(params, devicesOrder)
const includeCountryCode = !!params.deviceIds?.length
Expand Down
3 changes: 3 additions & 0 deletions supabase/functions/_backend/utils/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions tests/cloudflare-device-pagination.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof createClient>)

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<typeof createClient>)

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 })
})
})
130 changes: 130 additions & 0 deletions tests/device.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()
Expand Down
Loading