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:
@@ -56,6 +56,7 @@ User-visible session ordering is also not owned by the global cache array order.
|
|||||||
|
|
||||||
Global refresh rules:
|
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.
|
- 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.
|
- 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.
|
- 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'])
|
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 () => {
|
test('retries SDK error responses before treating the load as failed', async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const apiClient = {
|
const apiClient = {
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ const unwrapSessionList = (
|
|||||||
return result.data as GlobalSessionRecord[];
|
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(
|
export async function listGlobalSessionPages(
|
||||||
apiClient: OpencodeClient,
|
apiClient: OpencodeClient,
|
||||||
options: {
|
options: {
|
||||||
@@ -131,15 +140,22 @@ export async function listGlobalSessionPages(
|
|||||||
});
|
});
|
||||||
if (payload.length === 0) break;
|
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;
|
let appended = 0;
|
||||||
|
const accepted: GlobalSessionRecord[] = [];
|
||||||
for (const session of payload) {
|
for (const session of payload) {
|
||||||
if (!session?.id || seenIds.has(session.id)) continue;
|
if (!session?.id || seenIds.has(session.id)) continue;
|
||||||
seenIds.add(session.id);
|
seenIds.add(session.id);
|
||||||
all.push(session);
|
|
||||||
appended += 1;
|
appended += 1;
|
||||||
|
if (options.archived && !isArchivedSession(session)) continue;
|
||||||
|
all.push(session);
|
||||||
|
accepted.push(session);
|
||||||
}
|
}
|
||||||
if (appended > 0) {
|
if (accepted.length > 0) {
|
||||||
options.onPage?.(payload);
|
options.onPage?.(accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop on partial page — nothing more to fetch.
|
// Stop on partial page — nothing more to fetch.
|
||||||
|
|||||||
Reference in New Issue
Block a user