Merge pull request #2573 from alexandrereyes/fix/archived-session-query-boundary

fix(sync): narrow the archived session query at the data boundary
This commit is contained in:
Bohdan Triapitsyn
2026-08-02 17:09:32 +03:00
committed by GitHub
3 changed files with 178 additions and 3 deletions
+1
View File
@@ -56,6 +56,7 @@ User-visible session ordering is also not owned by the global cache array order.
Global refresh rules:
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
@@ -96,6 +96,164 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_root', 'ses_child_1', 'ses_child_2'])
})
test('returns only archived sessions when archived pages are requested', async () => {
const apiClient = {
experimental: {
session: {
list: async () => ({
// The server treats `archived: true` as "include archived", so the
// response mixes active and archived records.
data: [
{ id: 'ses_active', time: { created: 1, updated: 20 } },
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 500 })
expect(sessions.map((session) => session.id)).toEqual(['ses_archived'])
})
test('keeps every record when active pages are requested', async () => {
const apiClient = {
experimental: {
session: {
list: async () => ({
data: [
{ id: 'ses_active_1', time: { updated: 20 } },
{ id: 'ses_active_2', time: { updated: 10 } },
],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: false, pageSize: 500 })
expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2'])
})
test('keeps paginating archived pages that are full of non-archived records', async () => {
const calls: Array<Record<string, unknown>> = []
const apiClient = {
experimental: {
session: {
list: async (options: Record<string, unknown>) => {
calls.push(options)
if (options.cursor === undefined) {
return {
data: [
{ id: 'ses_active_1', time: { updated: 30 } },
{ id: 'ses_active_2', time: { updated: 20 } },
],
response: { headers: new Headers({ 'x-next-cursor': '20' }) },
}
}
return {
data: [
{ id: 'ses_archived', time: { updated: 10, archived: 12 } },
],
response: { headers: new Headers() },
}
},
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 })
// A page that is full upstream but fully filtered out here must not be
// mistaken for the last page: pagination progress is measured on the raw
// response, not on the accepted records.
expect(calls).toHaveLength(2)
expect(sessions.map((session) => session.id)).toEqual(['ses_archived'])
})
test('reports only accepted records to onPage for archived pages', async () => {
const pages: string[][] = []
const apiClient = {
experimental: {
session: {
list: async () => ({
data: [
{ id: 'ses_active', time: { updated: 20 } },
{ id: 'ses_archived', time: { updated: 10, archived: 12 } },
],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
await listGlobalSessionPages(apiClient, {
archived: true,
pageSize: 500,
onPage: (sessions) => pages.push(sessions.map((session) => session.id)),
})
expect(pages).toEqual([['ses_archived']])
})
test('does not notify onPage for an archived page with no archived records', async () => {
const pages: string[][] = []
const apiClient = {
experimental: {
session: {
list: async () => ({
data: [{ id: 'ses_active', time: { updated: 20 } }],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, {
archived: true,
pageSize: 500,
onPage: (page) => pages.push(page.map((session) => session.id)),
})
expect(sessions).toEqual([])
expect(pages).toEqual([])
})
test('dedupes archived records by id and stops when a page repeats known ids', async () => {
const calls: Array<Record<string, unknown>> = []
const page = [
{ id: 'ses_archived_1', time: { updated: 30, archived: 31 } },
{ id: 'ses_archived_2', time: { updated: 20, archived: 21 } },
]
const apiClient = {
experimental: {
session: {
list: async (options: Record<string, unknown>) => {
calls.push(options)
return {
data: page,
response: {
headers: new Headers({
'x-next-cursor': options.cursor === undefined ? '20' : '10',
}),
},
}
},
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: true, pageSize: 2 })
// The second page repeats ids already seen, so the dedupe guard stops the
// loop and no record is returned twice.
expect(calls).toHaveLength(2)
expect(sessions.map((session) => session.id)).toEqual(['ses_archived_1', 'ses_archived_2'])
})
test('retries SDK error responses before treating the load as failed', async () => {
let calls = 0
const apiClient = {
+19 -3
View File
@@ -75,6 +75,15 @@ const unwrapSessionList = (
return result.data as GlobalSessionRecord[];
};
/**
* OpenCode's `archived` query flag means "also include archived sessions", not
* "return only archived sessions": the server simply drops its
* `time_archived IS NULL` condition. Callers that ask for the archived list
* expect archived-only records, so narrow the response here, at the data
* boundary, instead of leaving every consumer to re-derive it.
*/
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
export async function listGlobalSessionPages(
apiClient: OpencodeClient,
options: {
@@ -131,15 +140,22 @@ export async function listGlobalSessionPages(
});
if (payload.length === 0) break;
// `appended` tracks pagination progress over the raw response, while
// `accepted` holds the records this call actually returns. Filtering
// must not feed the pagination guards below, otherwise a page that is
// full upstream but mostly non-archived would look like a last page.
let appended = 0;
const accepted: GlobalSessionRecord[] = [];
for (const session of payload) {
if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id);
all.push(session);
appended += 1;
if (options.archived && !isArchivedSession(session)) continue;
all.push(session);
accepted.push(session);
}
if (appended > 0) {
options.onPage?.(payload);
if (accepted.length > 0) {
options.onPage?.(accepted);
}
// Stop on partial page — nothing more to fetch.