Remove query cache in favor of stateless cursors

Encode query parameters (filter, ordering, fullTextFilter) directly in
cursors instead of caching result sets. Simplifies the implementation
by removing mutable state while keeping the same behavior.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Patrik Oldsberg
2026-02-06 23:58:23 +01:00
parent 9cf676284e
commit 9a8f6aff95
2 changed files with 102 additions and 74 deletions
@@ -685,10 +685,9 @@ describe('InMemoryCatalogClient', () => {
it('returns empty result for invalid cursor', async () => {
const client = new InMemoryCatalogClient({ entities });
const cursor = Buffer.from(
JSON.stringify({ id: 'nonexistent', offset: 0, totalItems: 0 }),
).toString('base64');
const result = await client.queryEntities({ cursor });
const result = await client.queryEntities({
cursor: 'not-valid-base64!',
});
expect(result).toEqual({ items: [], pageInfo: {}, totalItems: 0 });
});
});
@@ -61,6 +61,53 @@ function parseCursor(cursor: string): Record<string, unknown> {
return JSON.parse(Buffer.from(cursor, 'base64').toString('utf8'));
}
// CATALOG_FILTER_EXISTS is a Symbol that doesn't survive JSON serialization,
// so we swap it with a sentinel string when encoding/decoding cursors.
const FILTER_EXISTS_SENTINEL = '\0CATALOG_FILTER_EXISTS';
function serializeFilterValue(v: unknown): unknown {
if (v === CATALOG_FILTER_EXISTS) return FILTER_EXISTS_SENTINEL;
if (Array.isArray(v)) {
return v.map(x =>
x === CATALOG_FILTER_EXISTS ? FILTER_EXISTS_SENTINEL : x,
);
}
return v;
}
function deserializeFilterValue(v: unknown): unknown {
if (v === FILTER_EXISTS_SENTINEL) return CATALOG_FILTER_EXISTS;
if (Array.isArray(v)) {
return v.map(x =>
x === FILTER_EXISTS_SENTINEL ? CATALOG_FILTER_EXISTS : x,
);
}
return v;
}
function serializeFilter(filter?: EntityFilterQuery): any[] | undefined {
if (!filter) return undefined;
return [filter]
.flat()
.map(f =>
Object.fromEntries(
Object.entries(f).map(([k, v]) => [k, serializeFilterValue(v)]),
),
);
}
function deserializeFilter(filter?: any[]): EntityFilterQuery | undefined {
if (!filter) return undefined;
return filter.map(f =>
Object.fromEntries(
Object.entries(f).map(([k, v]: [string, any]) => [
k,
deserializeFilterValue(v),
]),
),
);
}
function buildEntitySearch(entity: Entity) {
const rows = traverse(entity);
@@ -198,11 +245,11 @@ function applyOrdering(entities: Entity[], order?: EntityOrderQuery): Entity[] {
const aRow = aRows.find(r => r.key.toLocaleLowerCase('en-US') === key);
const bRow = bRows.find(r => r.key.toLocaleLowerCase('en-US') === key);
const aValue =
aRow?.value != null
aRow?.value !== null && aRow?.value !== undefined
? String(aRow.value).toLocaleLowerCase('en-US')
: null;
const bValue =
bRow?.value != null
bRow?.value !== null && bRow?.value !== undefined
? String(bRow.value).toLocaleLowerCase('en-US')
: null;
@@ -217,7 +264,9 @@ function applyOrdering(entities: Entity[], order?: EntityOrderQuery): Entity[] {
// Stable tie-breaker by entity ref, matching backend behavior
const aRef = stringifyEntityRef(a);
const bRef = stringifyEntityRef(b);
return aRef < bRef ? -1 : aRef > bRef ? 1 : 0;
if (aRef < bRef) return -1;
if (aRef > bRef) return 1;
return 0;
});
}
@@ -240,9 +289,9 @@ function applyFullTextFilter(
) {
return false;
}
const value =
row.value != null ? String(row.value).toLocaleLowerCase('en-US') : null;
return value != null && value.includes(term);
if (row.value === null || row.value === undefined) return false;
const value = String(row.value).toLocaleLowerCase('en-US');
return value.includes(term);
});
});
}
@@ -257,8 +306,6 @@ function applyFullTextFilter(
*/
export class InMemoryCatalogClient implements CatalogApi {
#entities: Entity[];
#queryCache = new Map<string, Entity[]>();
#nextQueryId = 0;
constructor(options?: { entities?: Entity[] }) {
this.#entities = options?.entities?.slice() ?? [];
@@ -277,8 +324,8 @@ export class InMemoryCatalogClient implements CatalogApi {
if (request?.after) {
try {
const cursor = parseCursor(request.after);
if (cursor.offset != null) offset = cursor.offset as number;
if (cursor.limit != null) limit = cursor.limit as number;
if (cursor.offset !== undefined) offset = cursor.offset as number;
if (cursor.limit !== undefined) limit = cursor.limit as number;
} catch {
// ignore invalid cursor
}
@@ -287,7 +334,7 @@ export class InMemoryCatalogClient implements CatalogApi {
if (offset > 0) {
items = items.slice(offset);
}
if (limit != null) {
if (limit !== undefined) {
items = items.slice(0, limit);
}
@@ -316,70 +363,53 @@ export class InMemoryCatalogClient implements CatalogApi {
async queryEntities(
request?: QueryEntitiesRequest,
): Promise<QueryEntitiesResponse> {
// Decode query parameters from cursor or from the request directly
let filter: EntityFilterQuery | undefined;
let orderFields: EntityOrderQuery | undefined;
let fullTextFilter: { term: string; fields?: string[] } | undefined;
let offset: number;
let limit: number | undefined;
if (request && 'cursor' in request) {
const cursorData = parseCursor(request.cursor);
const { id, offset, totalItems } = cursorData as {
id: string;
offset: number;
totalItems: number;
};
const cachedItems = this.#queryCache.get(id);
if (!cachedItems) {
let c: Record<string, unknown>;
try {
c = parseCursor(request.cursor);
} catch {
return { items: [], pageInfo: {}, totalItems: 0 };
}
const limit = request.limit ?? cachedItems.length;
const pageItems = cachedItems.slice(offset, offset + limit);
const pageInfo: QueryEntitiesResponse['pageInfo'] = {};
if (offset + limit < totalItems) {
pageInfo.nextCursor = makeCursor({
id,
offset: offset + limit,
totalItems,
});
}
if (offset > 0) {
pageInfo.prevCursor = makeCursor({
id,
offset: Math.max(0, offset - limit),
totalItems,
});
}
return {
items: request.fields
? pageItems.map(e => applyFieldsFilter(e, request.fields))
: pageItems,
totalItems,
pageInfo,
};
filter = deserializeFilter(c.filter as any[]);
orderFields = c.orderFields as EntityOrderQuery | undefined;
fullTextFilter = c.fullTextFilter as typeof fullTextFilter;
offset = c.offset as number;
limit = request.limit;
} else {
filter = request?.filter;
orderFields = request?.orderFields;
fullTextFilter = request?.fullTextFilter;
offset = request?.offset ?? 0;
limit = request?.limit;
}
const filter = createFilter(request?.filter);
let items = this.#entities.filter(filter);
// Apply filter
let items = this.#entities.filter(createFilter(filter));
// Resolve full-text filter default fields to match catalog backend behavior:
// when no fields are specified, search the first sort field, or metadata.uid
if (request?.fullTextFilter) {
const orderFields = request.orderFields
? [request.orderFields].flat()
: [];
// Apply full-text filter, defaulting to the sort field or metadata.uid
if (fullTextFilter) {
const orderFieldsList = orderFields ? [orderFields].flat() : [];
items = applyFullTextFilter(items, {
...request.fullTextFilter,
fields: request.fullTextFilter.fields ?? [
orderFields[0]?.field ?? 'metadata.uid',
...fullTextFilter,
fields: fullTextFilter.fields ?? [
orderFieldsList[0]?.field ?? 'metadata.uid',
],
});
}
items = applyOrdering(items, request?.orderFields);
items = applyOrdering(items, orderFields);
const totalItems = items.length;
const offset = request?.offset ?? 0;
const limit = request?.limit;
// No pagination requested, return all items
if (limit == null && offset === 0) {
if (limit === undefined && offset === 0) {
return {
items: request?.fields
? items.map(e => applyFieldsFilter(e, request?.fields))
@@ -390,26 +420,25 @@ export class InMemoryCatalogClient implements CatalogApi {
}
const effectiveLimit = limit ?? totalItems;
// Cache the full result set for cursor-based pagination
const id = String(this.#nextQueryId++);
this.#queryCache.set(id, items);
const pageItems = items.slice(offset, offset + effectiveLimit);
const cursorBase = {
filter: serializeFilter(filter),
orderFields,
fullTextFilter,
totalItems,
};
const pageInfo: QueryEntitiesResponse['pageInfo'] = {};
if (offset + effectiveLimit < totalItems) {
pageInfo.nextCursor = makeCursor({
id,
...cursorBase,
offset: offset + effectiveLimit,
totalItems,
});
}
if (offset > 0) {
pageInfo.prevCursor = makeCursor({
id,
...cursorBase,
offset: Math.max(0, offset - effectiveLimit),
totalItems,
});
}
@@ -472,7 +501,7 @@ export class InMemoryCatalogClient implements CatalogApi {
facet.toLocaleLowerCase('en-US'),
)
.map(row => row.value)
.filter(v => v != null)
.filter(v => v !== null && v !== undefined)
.map(v => String(v)),
);
for (const value of uniqueValues) {