Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
22060ff
copy Omicron's sort order for mock API items
charliepark Feb 5, 2026
f7b01d8
A few tweaks for datetime comparisons
charliepark Feb 5, 2026
9a664ef
locale-agnostic comparisons; handle invalid dates
charliepark Feb 5, 2026
942f882
refactor; handle different page tokens
charliepark Feb 5, 2026
bb63b6d
npm run fmt
charliepark Feb 5, 2026
c47a58e
normalizeTime util for tokens
charliepark Feb 5, 2026
5dc74d3
Improvement for missing time data
charliepark Feb 5, 2026
d53f306
Merge branch 'main' into sort_mock_api_items
charliepark Feb 6, 2026
f0d0c23
Use Remeda's sortBy to clean up ordering
charliepark Feb 6, 2026
c181dd3
npm run fmt
charliepark Feb 6, 2026
5b5199e
Update tests to use first item in list
charliepark Feb 6, 2026
c761321
Revert to edge case for snapshot-max-size
charliepark Feb 6, 2026
24d2a0d
Remove unnecessary comments
charliepark Feb 6, 2026
6e92ed2
tiebreaker
charliepark Feb 6, 2026
def619f
Merge main
charliepark Feb 18, 2026
f64e35b
A few util updates
charliepark Feb 18, 2026
070e518
Test refactors
charliepark Feb 18, 2026
0ce8b3c
PR review fixes
charliepark Feb 18, 2026
a89939a
Revert to working test
charliepark Feb 18, 2026
e07eac2
npm run fmt
charliepark Feb 18, 2026
434be21
Remeda is your friend
charliepark Feb 18, 2026
a5c5cc5
no need to spread items array
charliepark Feb 19, 2026
169fcbc
Another comment patch
charliepark Feb 20, 2026
0713623
use first image in e2e test helper when no string passed
charliepark Feb 20, 2026
c5367ff
use existing types
charliepark Feb 21, 2026
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
7 changes: 5 additions & 2 deletions mock-api/msw/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ describe('paginated', () => {
const items = Array.from({ length: 200 }).map((_, i) => ({ id: 'i' + i }))
const page = paginated({}, items)
expect(page.items.length).toBe(100)
expect(page.items).toEqual(items.slice(0, 100))
expect(page.next_page).toBe('i100')
// Items are sorted by id lexicographically (matching Omicron's UUID sorting behavior)
// Use locale-agnostic comparison to match the implementation
const sortedItems = [...items].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
expect(page.items).toEqual(sortedItems.slice(0, 100))
expect(page.next_page).toBe(sortedItems[100].id)
})

it('should return page with null `next_page` if items equal page', () => {
Expand Down
171 changes: 164 additions & 7 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,42 +39,199 @@ import { getMockOxqlInstanceData } from '../oxql-metrics'
import { db, lookupById } from './db'
import { Rando } from './rando'

type SortMode =
| 'name_ascending'
| 'name_descending'
| 'id_ascending'
| 'time_and_id_ascending'
| 'time_and_id_descending'

interface PaginateOptions {
limit?: number | null
pageToken?: string | null
sortBy?: SortMode
}

export interface ResultsPage<I extends { id: string }> {
items: I[]
next_page: string | null
}

/**
* Normalize a timestamp to a canonical string format for use in page tokens.
* Ensures consistency between token generation and parsing.
*/
function normalizeTime(t: unknown): string {
if (t instanceof Date) {
return t.toISOString()
}
if (typeof t === 'string') {
return t
}
return ''
}

/**
* Sort items based on the sort mode. Implements default sorting behavior to
* match Omicron's pagination defaults.
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L427-L428
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L334-L335
* https://github.com/oxidecomputer/omicron/blob/cf38148/common/src/api/external/http_pagination.rs#L511-L512
*/
function sortItems<I extends { id: string }>(items: I[], sortBy: SortMode): I[] {
const sorted = [...items]

switch (sortBy) {
case 'name_ascending':
return sorted.sort((a, b) => {
// Use byte-wise lexicographic comparison to match Rust's String ordering,
// not locale-aware localeCompare()
const aName = 'name' in a ? String(a.name) : a.id
const bName = 'name' in b ? String(b.name) : b.id
return aName < bName ? -1 : aName > bName ? 1 : 0
})
case 'name_descending':
return sorted.sort((a, b) => {
const aName = 'name' in a ? String(a.name) : a.id
const bName = 'name' in b ? String(b.name) : b.id
return bName < aName ? -1 : bName > aName ? 1 : 0
})
case 'id_ascending':
// Use pure lexicographic comparison for UUIDs to match Rust's derived Ord
// and avoid locale-dependent behavior
return sorted.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
case 'time_and_id_ascending':
return sorted.sort((a, b) => {
// Compare timestamps numerically to handle Date objects and non-ISO formats
// Normalize NaN from invalid dates to -Infinity for deterministic ordering
const aRaw =
'time_created' in a
? new Date(a.time_created as string | Date).valueOf()
: -Infinity
const bRaw =
'time_created' in b
? new Date(b.time_created as string | Date).valueOf()
: -Infinity
const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity
const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity
const timeCompare = aTime - bTime
return timeCompare !== 0 ? timeCompare : a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
case 'time_and_id_descending':
return sorted.sort((a, b) => {
// Compare timestamps numerically to handle Date objects and non-ISO formats
// Normalize NaN from invalid dates to -Infinity for deterministic ordering
const aRaw =
'time_created' in a
? new Date(a.time_created as string | Date).valueOf()
: -Infinity
const bRaw =
'time_created' in b
? new Date(b.time_created as string | Date).valueOf()
: -Infinity
const aTime = Number.isFinite(aRaw) ? aRaw : -Infinity
const bTime = Number.isFinite(bRaw) ? bRaw : -Infinity
const timeCompare = bTime - aTime
return timeCompare !== 0 ? timeCompare : b.id < a.id ? -1 : b.id > a.id ? 1 : 0
})
}
}
Comment thread
david-crespo marked this conversation as resolved.

/**
* Get the page token value for an item based on the sort mode.
* Matches Omicron's marker types for each scan mode.
*/
function getPageToken<I extends { id: string }>(item: I, sortBy: SortMode): string {
switch (sortBy) {
case 'name_ascending':
case 'name_descending':
// ScanByNameOrId uses Name as marker for name-based sorting
return 'name' in item ? String(item.name) : item.id
case 'id_ascending':
// ScanById uses Uuid as marker
return item.id
case 'time_and_id_ascending':
case 'time_and_id_descending':
// ScanByTimeAndId uses (DateTime, Uuid) tuple as marker
// Serialize as "timestamp|id" (using | since timestamps contain :)
const time = 'time_created' in item ? normalizeTime(item.time_created) : ''
return `${time}|${item.id}`
}
}

/**
* Find the start index for pagination based on the page token and sort mode.
* Handles different marker types matching Omicron's pagination behavior.
*/
function findStartIndex<I extends { id: string }>(
sortedItems: I[],
pageToken: string,
sortBy: SortMode
): number {
switch (sortBy) {
case 'name_ascending':
case 'name_descending':
// Page token is a name - find first item with this name
return sortedItems.findIndex((i) =>
'name' in i ? i.name === pageToken : i.id === pageToken
)
case 'id_ascending':
// Page token is an ID
return sortedItems.findIndex((i) => i.id === pageToken)
case 'time_and_id_ascending':
case 'time_and_id_descending':
// Page token is "timestamp|id" - find item with matching timestamp and ID
// Use same fallback as getPageToken for items without time_created
const [time, id] = pageToken.split('|', 2)
return sortedItems.findIndex((i) => {
const itemTime = 'time_created' in i ? normalizeTime(i.time_created) : ''
return i.id === id && itemTime === time
})
}
}

export const paginated = <P extends PaginateOptions, I extends { id: string }>(
params: P,
items: I[]
) => {
const limit = params.limit || 100
const pageToken = params.pageToken

let startIndex = pageToken ? items.findIndex((i) => i.id === pageToken) : 0
startIndex = startIndex < 0 ? 0 : startIndex
// Apply default sorting based on what fields are available, matching Omicron's defaults:
// - name_ascending for endpoints that support name/id sorting (most common)
// - id_ascending for endpoints that only support id sorting
// Note: time_and_id_ascending is only used when explicitly specified in sortBy
const sortBy =
params.sortBy ||
(items.length > 0 && 'name' in items[0] ? 'name_ascending' : 'id_ascending')

const sortedItems = sortItems(items, sortBy)

let startIndex = pageToken ? findStartIndex(sortedItems, pageToken, sortBy) : 0

// Warn if page token not found - helps catch bugs in tests
if (pageToken && startIndex < 0) {
console.warn(`Page token "${pageToken}" not found, starting from beginning`)
startIndex = 0
}

if (startIndex > items.length) {
if (startIndex > sortedItems.length) {
return {
items: [],
next_page: null,
}
}

if (limit + startIndex >= items.length) {
if (limit + startIndex >= sortedItems.length) {
return {
items: items.slice(startIndex),
items: sortedItems.slice(startIndex),
next_page: null,
}
}

return {
items: items.slice(startIndex, startIndex + limit),
next_page: `${items[startIndex + limit].id}`,
items: sortedItems.slice(startIndex, startIndex + limit),
next_page: getPageToken(sortedItems[startIndex + limit], sortBy),
}
}

Expand Down
Loading